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.


How it works

  1. 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.
  2. 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.
  3. The member enters the code. Your site posts it to POST /auth/verify and receives a member token.
  4. Your site reads the data with Authorization: Bearer {token}: GET /me, GET /storecards, GET /coupons and GET /tier.
  5. 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 as https://shop.example.com but not the bare https://example.com. Add both when you need both.

The origin check is the browser's CORS mechanism:

  • A request whose Origin is on the list of the organization gets Access-Control-Allow-Origin (the origin itself) and Vary: Origin.
  • A request whose Origin is not on the list gets 403 origin_not_allowed without CORS headers, so the browser blocks it.
  • Preflight requests (OPTIONS) are answered 204 with Access-Control-Allow-Methods: GET, POST, OPTIONS, Access-Control-Allow-Headers: Authorization, Content-Type, Accept and Access-Control-Max-Age: 600 for an allowed origin, and 403 otherwise.
  • Requests without an Origin header (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/code gives 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/verify gives the same 422 invalid_or_expired_code for 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 as Authorization: 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 answers 403 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/code and POST /auth/verify, per IP address.

  • Name
    Code requests per address
    Type
    5 / hour
    Description

    POST /auth/code per e-mail address and organization. Applies to unknown addresses too, so a 429 reveals nothing about membership.

  • Name
    Code requests per IP
    Type
    20 / hour
    Description

    POST /auth/code per 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 200 and the code from the first mail stays valid.

  • Name
    Verify attempts per IP
    Type
    30 / hour
    Description

    POST /auth/verify per IP address. Above this limit the answer is 422 invalid_or_expired_code (not 429), 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 Origin of 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.


POST/auth/code

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, es or fr.

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

POST
/auth/code
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
}

POST/auth/verify

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

POST
/auth/verify
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"
}

POST/auth/logout

Log out

Revokes the member token. Every later call with it answers 401 unauthenticated. No body.

Request

POST
/auth/logout
curl -X POST https://api.walletapp.co/api/widget/v1/auth/logout \
  -H "Authorization: Bearer {token}" \
  -H "Accept: application/json"

Response

{
  "status": "ok"
}

GET/me

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 null when 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

GET
/me
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"
  }
}

GET/storecards

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 as prepaid, postpaid or subscription.

  • 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_only and 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}; total can be null.

  • Name
    next_reward
    Type
    object|null
    Description

    The next reward on this card: title, unit (points or stamps) and remaining (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. null when there is none.

Request

GET
/storecards
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 }
    }
  ]
}

GET/coupons

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 the code to 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 null when the coupon does not expire.

  • Name
    code
    Type
    string|null
    Description

    The code to scan or show at the counter. Only in active; always null in recently_used.

  • Name
    used_at
    Type
    string
    Description

    Only in recently_used: ISO 8601 moment the coupon was used.

Request

GET
/coupons
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/tier

Get the tier

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 null without an active programme.

  • Name
    tier.program
    Type
    object
    Description

    name of the programme and its metric: points (points earned), visits or spend (in cents).

  • Name
    tier.levels
    Type
    array
    Description

    All levels from low to high, each {name, color, threshold}. color is a hex colour or null.

  • Name
    tier.level
    Type
    object|null
    Description

    The member's current level {name, color, threshold}, or null when they have not reached a level yet.

  • Name
    tier.next_level
    Type
    object|null
    Description

    The next level, or null at 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 (0 at the top level).

  • Name
    tier.progress
    Type
    number
    Description

    Progress from the current level to the next, 0 to 1 (1 at 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) and year (the year it would then also hold). Otherwise null.

  • Name
    tier.current_rewards
    Type
    object
    Description

    Benefits of the current level: points_multiplier (for example 1.5, or null when there is none) and recurring_coupon ({title, image, interval} with interval monthly or quarterly, or null).

  • Name
    tier.next_rewards
    Type
    object
    Description

    What the next level brings: reach_coupon ({title, subtitle, image}, the gift on reaching it, or null), points_multiplier and recurring_coupon as 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}. null without 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 smallest remaining wins. It is the next_reward of that card in GET /storecards. null when 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

GET
/tier
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"
    }
  }
}