Widget API
With the widget API your own website shows a logged-in member their own loyalty data: their storecards (points, stamps, balance), their coupons and their tier. The member logs in on your site with a one-time code we send to their e-mail address; your site then reads the data with a member token. No organization token or other secret ever goes into the browser.
The widget API is a browser API for one member at a time. It is separate from the integration API on the other pages: it does not use POST /oauth/token, it has its own base URL and its responses have no message / data envelope.
How it works
- Switch it on in the dashboard. An owner or admin of the organization opens Integrations → Website integration, turns the integration on and adds the website addresses (origins) that may connect, for example
https://www.example.com. The same page shows the organization identifier and the base URL you need. - The member asks for a code. Your site posts the member's e-mail address to
POST /auth/code. If the address belongs to a member of your organization, we e-mail a 6-digit code in your brand's name. - The member enters the code. Your site posts it to
POST /auth/verifyand receives a member token. - Your site reads the data with
Authorization: Bearer {token}:GET /me,GET /storecards,GET /couponsandGET /tier. - The member logs out with
POST /auth/logout.
Everything a token returns is scoped to one member in one organization: the member who logged in, in the organization they logged in to. Parameters such as organization or user_id on the read endpoints are ignored.
Base URL
https://api.walletapp.co/api/widget/v1
Endpoints
POST /auth/code public
POST /auth/verify public
POST /auth/logout member token
GET /me member token
GET /storecards member token
GET /coupons member token
GET /tier member token
Quickstart: log in and read the cards
A complete example for a page on your own site, in plain JavaScript. Replace ORGANIZATION with the organization identifier from the dashboard. The page's origin must be on the allowlist of your organization.
mijn-voordeel.js
const API = 'https://api.walletapp.co/api/widget/v1'
const ORGANIZATION = 'your-organization-identifier'
async function call(path, { method = 'GET', body, token } = {}) {
const headers = { Accept: 'application/json' }
if (body) headers['Content-Type'] = 'application/json'
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(`${API}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
const error = new Error(json.error || json.message || `HTTP ${res.status}`)
error.status = res.status
error.retryAfter = Number(res.headers.get('Retry-After')) || null
throw error
}
return json
}
// 1. Ask for a code. The answer is the same whether or not the address is a member.
async function requestCode(email) {
await call('/auth/code', {
method: 'POST',
body: { organization: ORGANIZATION, email, locale: 'en' },
})
}
// 2. Exchange the code for a member token.
async function verify(email, code) {
const { token, expires_at } = await call('/auth/verify', {
method: 'POST',
body: { organization: ORGANIZATION, email, code },
})
sessionStorage.setItem('walnut_widget_token', token)
return { token, expires_at }
}
// 3. Read the member's data with the token.
async function loadMemberData() {
const token = sessionStorage.getItem('walnut_widget_token')
if (!token) return null
try {
const [me, cards, coupons] = await Promise.all([
call('/me', { token }),
call('/storecards', { token }),
call('/coupons', { token }),
])
return { me, storecards: cards.storecards, coupons }
} catch (error) {
if (error.status === 401) {
// Token expired, revoked or the member left the organization: log in again.
sessionStorage.removeItem('walnut_widget_token')
return null
}
throw error
}
}
// 4. Log out.
async function logout() {
const token = sessionStorage.getItem('walnut_widget_token')
if (token) await call('/auth/logout', { method: 'POST', token }).catch(() => {})
sessionStorage.removeItem('walnut_widget_token')
}
Always send Accept: application/json, otherwise a validation error is not answered as JSON. Do not send credentials: 'include': the API uses no cookies and does not allow credentialed requests.
Origins and CORS
The widget API is protected per organization by two settings in the dashboard (Integrations → Website integration):
- On or off. While the integration is off, every call for that organization answers
403 forbidden, including calls with a token that was issued earlier. Switching it back on makes those tokens work again. - Allowed websites. A list of origins (scheme, host and optional port, no path), up to 25. An entry matches exactly, or as a wildcard
https://*.example.com, which matches every subdomain such ashttps://shop.example.combut not the barehttps://example.com. Add both when you need both.
The origin check is the browser's CORS mechanism:
- A request whose
Originis on the list of the organization getsAccess-Control-Allow-Origin(the origin itself) andVary: Origin. - A request whose
Originis not on the list gets403 origin_not_allowedwithout CORS headers, so the browser blocks it. - Preflight requests (
OPTIONS) are answered204withAccess-Control-Allow-Methods: GET, POST, OPTIONS,Access-Control-Allow-Headers: Authorization, Content-Type, AcceptandAccess-Control-Max-Age: 600for an allowed origin, and403otherwise. - Requests without an
Originheader (curl, a server) are not a CORS matter and are not checked against the list. They still need a valid code or member token, so the allowlist is not an access control for servers: it only decides which websites may call the API from a browser.
The organization identifier (organization in the login calls) is not a secret: it only says which organization the member logs in to. It is shown on the Website integration page in the dashboard.
Authentication
Members log in with a one-time code sent by e-mail; there are no passwords.
- The code has 6 digits, is valid for 10 minutes, can be used once, and allows at most 5 attempts. After 5 wrong attempts the code is dead, even the correct one; request a new code. Requesting a new code invalidates the previous one.
- No account enumeration.
POST /auth/codegives the same answer for every e-mail address, whether or not it belongs to a member of the organization; only members receive a mail.POST /auth/verifygives the same422 invalid_or_expired_codefor a wrong code, an expired code, a used code and an unknown address. - The member token starts with
wmt_, is valid for 30 days and slides: every use moves the expiry to 30 days after that use (updated at most once per hour). Send it asAuthorization: Bearer {token}. - A token ends when it expires, when the member logs out, or when the member no longer belongs to the organization (
401 unauthenticated). While the integration is switched off, the token answers403 forbidden.
Store the token in memory or sessionStorage on your site, never in a URL.
Rate limits
When a limit is reached the API answers 429 with {"error": "too_many_requests"} and a Retry-After header (seconds). An allowed origin also gets its CORS headers on a 429, so your site can read it.
- Name
Public calls- Type
- 120 / minute
- Description
POST /auth/codeandPOST /auth/verify, per IP address.
- Name
Code requests per address- Type
- 5 / hour
- Description
POST /auth/codeper e-mail address and organization. Applies to unknown addresses too, so a429reveals nothing about membership.
- Name
Code requests per IP- Type
- 20 / hour
- Description
POST /auth/codeper IP address, across all addresses.
- Name
Resend cooldown- Type
- 30 seconds
- Description
A second code request for the same address within 30 seconds sends no new mail. It still answers
200and the code from the first mail stays valid.
- Name
Verify attempts per IP- Type
- 30 / hour
- Description
POST /auth/verifyper IP address. Above this limit the answer is422 invalid_or_expired_code(not429), like any other failed verification.
- Name
Token calls- Type
- 240 / minute
- Description
All endpoints that take a member token, per token.
- Name
GET /tier- Type
- 30 / minute
- Description
Per token, on top of the 240 per minute. Other endpoints are not affected by it.
Errors
Errors are JSON with an error code. Validation errors use the standard message / errors shape.
- Name
401 unauthenticated- Type
- token
- Description
No token, an unknown, expired or revoked token, or the member no longer belongs to the organization. Send the member back to the login.
- Name
403 forbidden- Type
- all
- Description
The organization identifier is unknown, or the website integration of the organization is off. Both give the same answer.
- Name
403 origin_not_allowed- Type
- all
- Description
The
Originof the request is not on the organization's list of allowed websites. Sent without CORS headers, so a browser shows a CORS error instead.
- Name
422 invalid_or_expired_code- Type
- verify
- Description
The code is wrong, expired, already used or out of attempts, or the address is not a member. One answer for all cases.
- Name
422 (validation)- Type
- auth
- Description
A required field is missing or malformed:
{"message": "...", "errors": {"email": ["..."]}}.
- Name
429 too_many_requests- Type
- all
- Description
A rate limit was reached. Wait the number of seconds in
Retry-After.
- Name
500 unavailable- Type
- tier
- Description
Only on
GET /tier: the tier could not be read. Hide the tier part of your page and keep the rest.
Request a login code
Sends a 6-digit login code to the e-mail address when it belongs to a member of the organization. The mail is sent in the name of your brand (name, logo and colour from the dashboard), in the requested language.
The answer is always the same 200, whether or not the address belongs to a member, so it never tells a visitor whether an address is known. Show a neutral message such as "If this address is known to us, you will receive a code."
Body
- Name
organization- Type
- string
- Description
The organization identifier from the dashboard (max 100 characters).
- Name
email- Type
- string
- Description
The member's e-mail address (max 190 characters). Case and surrounding spaces do not matter.
- Name
locale- Type
- string
- Description
Language of the mail:
nl(default),en,de,esorfr.
Response
- Name
status- Type
- string
- Description
Always
ok.
- Name
expires_in- Type
- integer
- Description
Lifetime of the code in seconds (
600).
Errors: 403 forbidden, 403 origin_not_allowed, 422 validation, 429 too_many_requests.
Request
curl -X POST https://api.walletapp.co/api/widget/v1/auth/code \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"organization":"your-organization-identifier","email":"alex@example.com","locale":"en"}'
Response
{
"status": "ok",
"expires_in": 600
}
Verify a code
Exchanges the code from the mail for a member token. A code works once; after 5 wrong attempts it no longer works at all.
Body
- Name
organization- Type
- string
- Description
The same organization identifier as in
POST /auth/code.
- Name
email- Type
- string
- Description
The same e-mail address.
- Name
code- Type
- string
- Description
The 6-digit code from the mail. Send it as a string, so leading zeros are kept.
Response
- Name
token- Type
- string
- Description
The member token, starting with
wmt_. It is shown only in this response.
- Name
token_type- Type
- string
- Description
Always
Bearer.
- Name
expires_at- Type
- string
- Description
ISO 8601 expiry, 30 days from now. The expiry slides forward while the token is used.
Errors: 422 invalid_or_expired_code, 403 forbidden, 403 origin_not_allowed, 422 validation, 429 too_many_requests.
Request
curl -X POST https://api.walletapp.co/api/widget/v1/auth/verify \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"organization":"your-organization-identifier","email":"alex@example.com","code":"042917"}'
Response
{
"token": "wmt_Qm9vZ3JhZmllWGFtcGxlVG9rZW5Pbmx5Rm9yRG9jczEyMzQ1",
"token_type": "Bearer",
"expires_at": "2026-12-13T10:15:00+01:00"
}
Error
{
"error": "invalid_or_expired_code"
}
Log out
Revokes the member token. Every later call with it answers 401 unauthenticated. No body.
Request
curl -X POST https://api.walletapp.co/api/widget/v1/auth/logout \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"
Response
{
"status": "ok"
}
Get the member
Returns who is logged in, for a greeting and a card number.
Response
- Name
first_name- Type
- string|null
- Description
The member's first name, or
nullwhen it is not known.
- Name
card_number- Type
- string|null
- Description
The code of the member's most recent pass in this organization (the number on their card), or
null.
- Name
organization.name- Type
- string
- Description
Name of the organization.
- Name
organization.identifier- Type
- string
- Description
The organization identifier.
Request
curl https://api.walletapp.co/api/widget/v1/me \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"
Response
{
"first_name": "Alex",
"card_number": "WLNT-7KQ2-M4XD-R5TB",
"organization": {
"name": "Example Car Wash",
"identifier": "your-organization-identifier"
}
}
List storecards
Returns the member's active storecards in this organization (at most 50), ordered by storecard. Passes that are redeemed, blocked or expired and archived storecards are left out.
Which value fields are filled depends on type; the others are null.
Response
- Name
storecards- Type
- array
- Description
The member's storecards, each with the fields below.
- Name
id- Type
- string
- Description
The storecard identifier.
- Name
title, subtitle- Type
- string|null
- Description
As set in the dashboard.
- Name
type- Type
- string
- Description
points_only,stampcard,strippenkaart(punch card), or a balance card such asprepaid,postpaidorsubscription.
- Name
image- Type
- string|null
- Description
Absolute URL of the storecard image.
- Name
primary_color, text_color- Type
- string|null
- Description
Colours of the storecard, for styling.
- Name
points- Type
- integer|null
- Description
Points, for
points_onlyand for balance cards with points enabled.
- Name
balance- Type
- number|null
- Description
Monetary balance with two decimals (for example
25.5), for balance cards.
- Name
stamps- Type
- object|null
- Description
For a
stampcard:{current, required}.
- Name
punches- Type
- object|null
- Description
For a
strippenkaart:{left, total};totalcan benull.
- Name
next_reward- Type
- object|null
- Description
The next reward on this card:
title,unit(pointsorstamps) andremaining(how many points or stamps are still needed). For points it is the cheapest active points reward of the organization that costs more than the current points; for stamps it is the card's reward.nullwhen there is none.
Request
curl https://api.walletapp.co/api/widget/v1/storecards \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"
Response
{
"storecards": [
{
"id": "SC-STAMP",
"title": "Stamp card",
"subtitle": "10 washes, the next one is free",
"type": "stampcard",
"image": "https://cdn.example.com/storecards/stamp.png",
"primary_color": "#1d3557",
"text_color": "#ffffff",
"balance": null,
"points": null,
"stamps": { "current": 7, "required": 10 },
"punches": null,
"next_reward": { "title": "Free wash", "unit": "stamps", "remaining": 3 }
},
{
"id": "SC-POINTS",
"title": "Savings points",
"subtitle": null,
"type": "points_only",
"image": null,
"primary_color": null,
"text_color": null,
"balance": null,
"points": 60,
"stamps": null,
"punches": null,
"next_reward": { "title": "Free coffee", "unit": "points", "remaining": 40 }
}
]
}
List coupons
Returns the member's coupons in this organization in two lists of at most 50 each.
active: not redeemed, not blocked and not expired; soonest expiry first, coupons without an expiry last. These carry thecodeto show at the counter.recently_used: redeemed in the last 30 days, most recent first. These never carry a code.
Response
- Name
active, recently_used- Type
- array
- Description
Coupons with the fields below.
- Name
id- Type
- string
- Description
A stable id for this coupon of this member. Use it as a key, do not parse it.
- Name
title, subtitle- Type
- string|null
- Description
As set in the dashboard.
- Name
image- Type
- string|null
- Description
Absolute URL of the coupon image.
- Name
expires_at- Type
- string|null
- Description
ISO 8601 expiry, or
nullwhen the coupon does not expire.
- Name
code- Type
- string|null
- Description
The code to scan or show at the counter. Only in
active; alwaysnullinrecently_used.
- Name
used_at- Type
- string
- Description
Only in
recently_used: ISO 8601 moment the coupon was used.
Request
curl https://api.walletapp.co/api/widget/v1/coupons \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"
Response
{
"active": [
{
"id": "C-FREE-COFFEE:84512",
"title": "Free coffee",
"subtitle": "With every wash",
"image": "https://cdn.example.com/coupons/coffee.png",
"expires_at": "2026-12-01T00:00:00+01:00",
"code": "CPN-48KD-2QX7"
}
],
"recently_used": [
{
"id": "C-WELCOME:83110",
"title": "Welcome gift",
"subtitle": null,
"image": null,
"expires_at": null,
"code": null,
"used_at": "2026-11-10T14:32:05+01:00"
}
]
}
Get the tier
GET /tier becomes available with the next backend release. Until then it answers 404.
Returns the member's tier in this organization and what they are saving for. The call is read-only: viewing it never changes the member's tier, never starts a tier and never triggers a webhook. The result is cached for up to 60 seconds per member. Limit: 30 calls per minute per token (429 with Retry-After).
Without an active tier programme (or a programme without levels) tier is null. saving_for.reward can still be filled from the member's storecards.
Units. counter, remaining, threshold and saving_for.tier.remaining are in the unit of the programme's metric: points, visits, or for spend cents (7550 is € 75.50). Format them on your site.
Response
- Name
tier- Type
- object|null
- Description
The member's tier, or
nullwithout an active programme.
- Name
tier.program- Type
- object
- Description
nameof the programme and itsmetric:points(points earned),visitsorspend(in cents).
- Name
tier.levels- Type
- array
- Description
All levels from low to high, each
{name, color, threshold}.coloris a hex colour ornull.
- Name
tier.level- Type
- object|null
- Description
The member's current level
{name, color, threshold}, ornullwhen they have not reached a level yet.
- Name
tier.next_level- Type
- object|null
- Description
The next level, or
nullat the top level.
- Name
tier.counter- Type
- integer
- Description
What the member collected this calendar year, in the unit of the metric.
- Name
tier.remaining- Type
- integer
- Description
How much is still needed to reach
next_level(0at the top level).
- Name
tier.progress- Type
- number
- Description
Progress from the current level to the next,
0to1(1at the top level). Use it for a progress bar.
- Name
tier.year- Type
- integer
- Description
The calendar year of
counter.
- Name
tier.valid_until- Type
- string|null
- Description
Date (
YYYY-MM-DD) until which the current level holds. A level reached this year holds until 31 December of next year.
- Name
tier.reached_at- Type
- string|null
- Description
Date (
YYYY-MM-DD) the current level was reached, when known.
- Name
tier.retain- Type
- object|null
- Description
Set when the member keeps a level from last year that they have not earned again this year:
level(name),remaining(still needed this year to keep it) andyear(the year it would then also hold). Otherwisenull.
- Name
tier.current_rewards- Type
- object
- Description
Benefits of the current level:
points_multiplier(for example1.5, ornullwhen there is none) andrecurring_coupon({title, image, interval}withintervalmonthlyorquarterly, ornull).
- Name
tier.next_rewards- Type
- object
- Description
What the next level brings:
reach_coupon({title, subtitle, image}, the gift on reaching it, ornull),points_multiplierandrecurring_couponas above. Rewards show a title, subtitle and image only, never a coupon code. Coupons that are archived or have ended are left out.
- Name
saving_for.tier- Type
- object|null
- Description
The next level as a goal:
{level, remaining, metric}.nullwithout a programme or at the top level.
- Name
saving_for.reward- Type
- object|null
- Description
The nearest reward on one of the member's storecards:
{title, unit, remaining, storecard_id}. Points rewards come before stamp rewards; within a unit the smallestremainingwins. It is thenext_rewardof that card inGET /storecards.nullwhen no card has a next reward.
Errors: 401 unauthenticated, 403 forbidden, 403 origin_not_allowed, 429 too_many_requests, 500 unavailable (hide the tier part of your page, keep the rest).
Request
curl https://api.walletapp.co/api/widget/v1/tier \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"
Response
{
"tier": {
"program": { "name": "Example Rewards", "metric": "points" },
"levels": [
{ "name": "Bronze", "color": "#b08d57", "threshold": 100 },
{ "name": "Silver", "color": "#c0c0c0", "threshold": 300 },
{ "name": "Gold", "color": "#d4af37", "threshold": 600 }
],
"level": { "name": "Bronze", "color": "#b08d57", "threshold": 100 },
"next_level": { "name": "Silver", "color": "#c0c0c0", "threshold": 300 },
"counter": 180,
"remaining": 120,
"progress": 0.4,
"year": 2026,
"valid_until": "2027-12-31",
"reached_at": "2026-03-14",
"retain": null,
"current_rewards": {
"points_multiplier": null,
"recurring_coupon": null
},
"next_rewards": {
"reach_coupon": {
"title": "Free wash",
"subtitle": "One-time gift",
"image": "https://cdn.example.com/coupons/wash.png"
},
"points_multiplier": 1.5,
"recurring_coupon": {
"title": "Free coffee",
"image": "https://cdn.example.com/coupons/coffee.png",
"interval": "monthly"
}
}
},
"saving_for": {
"tier": { "level": "Silver", "remaining": 120, "metric": "points" },
"reward": {
"title": "Free coffee",
"unit": "points",
"remaining": 40,
"storecard_id": "SC-POINTS"
}
}
}
No tier programme
{
"tier": null,
"saving_for": {
"tier": null,
"reward": {
"title": "Free coffee",
"unit": "points",
"remaining": 40,
"storecard_id": "SC-POINTS"
}
}
}
