Partner API

Add loyalty to your point of sale in two calls

Your till already knows who the customer is and what they spent. We only need you to tell us. There is no ticket model to mirror and no state to keep in sync.

  • Who is this?

    POST /api/v1/customers/match

    Send whatever the customer showed — pass QR, card number, phone, or email. Get back a customer id and their current progress.

  • Give them the points.

    POST /api/v1/stamps

    Send that customer id, the sale total, and your own reference for the sale. Retries are safe.

What this API is for

Three independent jobs. Most connectors only need the first.

Loyalty — the POS owns the ticket and the till. This part answers two questions and nothing else:

  1. Who is this? — POST /api/v1/customers/match turns an identifier the till already holds (a scanned pass, a membership number, a phone number, an email) into our customerId.
  2. Give them the points. — POST /api/v1/stamps awards stamps, or points for a sale amount, against that customerId.

Prepaid — selling and spending a multi-visit card (a 10-coffee pack, a 5-wash bundle). Two more endpoints, a separate scope, and no customerId involved:

  1. Sell a pack. — POST /api/v1/prepaid/sales issues a card and returns a claim URL you send to the buyer.
  2. Punch one out. — POST /api/v1/prepaid/deduct spends exactly one credit off a card by its pass serial.

Rewards — handing over what the customer earned. Two endpoints, two more scopes:

  1. What have they earned? — GET /api/v1/customers/:customerId/rewards lists every reward on the card with its state for this customer.
  2. Give it to them. — POST /api/v1/redemptions marks a reward redeemed.

Everything in between — the open ticket, the line items, the tender, the receipt — stays in the POS. We never model an open ticket, never correlate a customer to a sale by timestamp, and never take a payment. If your integration design needs us to hold ticket state, it is the wrong design for this API.

Not in this surface, so do not design around them: creating loyalty customers, listing customers or cards, key introspection, and outgoing webhooks. Nothing here polls or pushes — every interaction is a request you make. Loyalty enrolment happens on the merchant's own public claim page (https://walletloyaltycard.com/c/<merchant-slug>); a connector links the shopper there rather than creating records through this API. (Prepaid is the exception that proves the rule: a sale does create a card, but it creates a card to be claimed, not a customer record.)

Plan requirement

Every endpoint on this surface requires the merchant to be on an active plan — a paid subscription or an in-progress trial. This is enforced on the credential itself, before scope is even considered, so there is no partially-working state to design around.

Merchant's planAPI
active — payingWorks.
preview — trialWorks. Note a trial ends, and the API stops with it unless they subscribe.
past_due — paying, payment failedWorks. We do not cut off a live till over a failed card; our dunning handles it.
free — never subscribed402 upgrade_required, reason: "plan_required".
cancelled — lapsed402 upgrade_required, reason: "subscription_cancelled".

Two things worth planning for:

  • A working integration can stop working without anything changing on your side, if the merchant's trial expires or their card fails long enough to cancel. Treat 402 as a real runtime state, surface the message to whoever is standing at the till, and do not retry.
  • reason tells you who to call. subscription_cancelled means the merchant needs to reactivate a plan they had; plan_required means they were never on one. Both are the merchant's to fix — there is nothing a connector can do about either.

Your merchant also cannot mint a key at all while on the free tier, so in practice you will usually hit this at setup rather than in production.

Locations

Every write endpoint requires a location. Not optional, no default, and no grace period — POST /api/v1/stamps, /prepaid/sales, /prepaid/deduct and /redemptions all reject a request without one.

Name the shop one of two ways, exactly one per request:

{ "location": { "storeId": "b3f1…" } }       // our id — authoritative
{ "location": { "locationRef": "oslo-03" } } // the merchant's own code for the shop
  • storeId is our uuid. Use it if your configuration can hold our identifiers.
  • locationRef is a code the merchant sets on each location in their own dashboard (More → Locations → Location code). Use it when it is easier to configure a till with oslo-03 than with a uuid. It is unique per merchant, not globally.

Send exactly one. Sending both is 400 invalid_location rather than a guess — if the two disagree there is no safe way to choose, and picking one would write a sale to a shop you did not name. A blank or whitespace-only value counts as absent.

The key decides which locations it may write for

When a merchant mints a key they choose the locations it covers; by default that is all of their active locations. A key writing for a shop outside its set gets 403 location_not_entitled — the location exists, this key is not allowed to use it.

An unknown location is 404 location_not_found. We never create one for you: a new location is a real change to the merchant's subscription, so it is theirs to add, in their dashboard. If the merchant has no locations at all, you get 409 no_locations_configured — that is a setup step they have not done, not something you can fix.

Why we ask

Locations are how the product is priced, so a sale we cannot attribute to a shop is a sale we cannot bill correctly. It is also the half your merchant cares about: once writes carry a location, their per-location reporting — revenue, new customers, redemptions per shop — starts working. Send the real one for each till; a franchise pushing six shops through one code makes their own dashboard useless to them.

Reads do not take a location. POST /api/v1/customers/match and the rewards read work the same from anywhere.

Integration flow

The whole loyalty connector, end to end. (Prepaid is a separate, shorter flow with no customer matching — see sales and deduct.)

  1. Once, at setup. The merchant's owner signs in to the dashboard and mints a key (see Getting a key), choosing which locations it covers. Your integration stores the plaintext — it is shown once and is unrecoverable afterwards. Agree at this point how each till will name its shop: our storeId, or the merchant's own location code. See Locations.
  2. At the point of sale, identify the shopper. Call POST /api/v1/customers/match with exactly one identifier. Three outcomes, all 200:
    • matched: true → you have a customerId; go to step 3.
    • matched: false with no reason → not a member. Offer the merchant's claim link; do not retry the same identifier.
    • matched: false, reason: "ambiguous" → two or more customers fit. Ask the shopper for their pass QR (serial) or their printed membership number (memberId), both of which are unique, and call again. Never pick one yourself — neither do we.
  3. Award the points. Call POST /api/v1/stamps with the customerId, the location this till serves, and an externalRef set to your own immutable id for that sale (payment id, ticket id, order id). That reference is the idempotency anchor — see Idempotency and retries.
  4. Show the result. The response carries progress.stampsSinceRedemption, progress.stampsRequired and progress.rewardReady, plus progress.rewards — every reward on the card with its state — and card.rewardDescription. That is enough to print a receipt line ("7 of 10 — next free coffee at 10") with no second round trip.
  5. If a reward is ready, hand it over. Optional, and only if the till is where rewards get given out. Call POST /api/v1/redemptions with a fresh externalRef — a different one from the sale in step 3 — and tell the customer what progress.forfeited says if it is non-zero. Skip this step entirely and the merchant redeems in their own app instead.

A connector that only ever stamps known customers can skip step 2 if it already stores our customerId, and step 5 is optional. Nothing else in the flow is.

Getting a key

You do not mint keys — the merchant does. Ask your merchant to sign in to their Wallet Loyalty dashboard and go to More → API keys, create one, and send it to you over a channel you both trust. Key management is not reachable from /api/v1 at all, by design: a key can neither mint nor revoke another key.

Owner only. Only the account owner can see or create keys — managers and staff cannot, and the screen will not show them anything. A key keeps working after a session ends, after a password reset, and after a staff member leaves, so the restriction is deliberate. If your merchant contact cannot find the screen, they are not the owner of that account.

PropertyValue
Formatwlk_ + 32 CSPRNG bytes, base64url — 47 characters total, e.g. wlk_9Qb2fT1hR7sVn0kZxA4cLpE6yWuMdG3iJoN8rB5tS2Y
At restSHA-256 of the plaintext, plus the first 12 characters kept in clear for display. The plaintext is never stored and never logged.
ShownExactly once, in the create response. There is no "reveal" and no reissue — a lost key must be revoked and replaced.
Display maskThe merchant's dashboard lists a key by its first 12 characters, wlk_9Qb2fT1h…, so they can tell keys apart without exposing one.
LabelRequired, 1–80 characters after trimming. Free text so keys are attributable — "Square connector", "Front till".
ScopesAt least one of customers:read, stamps:write, prepaid:write, rewards:read, rewards:write. Those five are the only scopes the API accepts; anything else is rejected at the schema level. A loyalty connector needs the first two, plus the rewards pair if the till hands rewards over; a prepaid connector needs only prepaid:write.
LocationsThe locations this key may write for — all of the merchant's active ones by default. See Locations. Ask the merchant to tick the shops your integration actually serves.
MerchantOne. A key never spans merchants, and there is no platform-wide key.
ExpiryNone. A key is valid until it is revoked. There is no expiry and no api_key_expired error — do not write a handler for one.
RevocationSets revokedAt; the row is never deleted, so it stays visible in the list and in the audit trail. Revoking an already-revoked key is an idempotent no-op, not an error.

Creating or revoking a key is recorded in the merchant's audit trail with the label and scopes — never the key itself.

lastUsedAt on a key is refreshed at most once per minute per key, so it is a "roughly when" for a human glancing at the list, not a precise last-request timestamp.

Auth and transport

POST /api/v1/stamps HTTP/1.1
Host: api.walletloyaltycard.com
Authorization: Bearer wlk_9Qb2fT1hR7sVn0kZxA4cLpE6yWuMdG3iJoN8rB5tS2Y
Content-Type: application/json
  • Base URL is https://api.walletloyaltycard.com. Every endpoint is POST with a JSON body.
  • The Bearer scheme is matched case-insensitively; the token itself is not. The wlk_ prefix is what tells the middleware it is holding a machine key rather than a session token — a session token presented here is rejected as invalid_api_key, and an API key presented to a first-party route is not a session and will not authenticate.
  • Cookies are ignored. There is no browser-session path into /api/v1, which also means CSRF is structurally impossible there. Call these endpoints from your server, never from a browser: the CORS allow-list does not include partner origins, and shipping the key to a browser would publish it.
  • Every response uses the same envelope: { "ok": true, ... } on success, { "ok": false, "error": "snake_case_code" } on failure. Branch on ok, then on error.

Rate limiting

120 requests per 60 seconds, per key. The 121st request in a window gets 429 { "ok": false, "error": "rate_limited" }.

Details a client should build against:

  • Keyed on the API key's id, not on your IP — a NATed till does not share a bucket with unrelated traffic, and rotating IPs does not buy more quota.
  • The window is anchored on the first request in it and resets 60 seconds later; it is not a strictly rolling window, so quota can return in a step rather than gradually.
  • The counter is checked after the credential and scope checks, so a 401/403 takes precedence over a 429.
  • No Retry-After header and no X-RateLimit-* headers. Back off on your own clock — a fixed wait of a few seconds, then retry. Do not hot-loop.
  • Treat the limit as a ceiling to stay under rather than a quota to meter against; it can reset earlier than you expect, and building a client that rides the boundary will eventually fail.

POST /api/v1/customers/match

Resolve an identifier the till already holds into our customerId. The linchpin of every connector: a POS knows a phone number or a scanned barcode, not our ids.

Auth: API key (customers:read) Plan gate: none — a read answers regardless of the merchant's plan status

Request

Exactly one identifier per request. A field that is present but blank or whitespace does not count as supplied. Zero identifiers, or two, is 400 invalid_request.

ParamInTypeRequiredNotes
serialbodystring (≤128)one ofThe wallet pass serial — exactly what a QR scan of the customer's pass yields, with no prefix or wrapper. Trimmed, then matched exactly. Unique platform-wide.
memberIdbodystring (≤64)one ofMembership/access card member id, VIP-XXXXXXXX or ACC-XXXXXXXX. Trimmed, then matched exactly — case-sensitive. Unique platform-wide.
phonebodystring (≤32)one ofCompared as digits, not as a string. Suffix match. Minimum 7 digits. See below.
emailbodystring (≤254)one ofTrimmed and lowercased, then compared for equality against the stored value.
curl -sS -X POST https://api.walletloyaltycard.com/api/v1/customers/match \
  -H 'Authorization: Bearer wlk_9Qb2fT1hR7sVn0kZxA4cLpE6yWuMdG3iJoN8rB5tS2Y' \
  -H 'Content-Type: application/json' \
  -d '{"phone":"+34 600 11 22 33"}'

Response 200 — matched

{
  "ok": true,
  "matched": true,
  "matchedOn": "phone",
  "customer": {
    "id": "3ac9f5d2-8b71-4e6c-9a02-71d4e8b3c159",
    "name": "Marta Ruiz",
    "progress": {
      "stampsSinceLastRedemption": 7,
      "totalStamps": 27,
      "totalRedemptions": 2,
      "rewardReady": false,
      "rewardsReadyCount": 0,
      "rewards": [
        {
          "id": "c93b1e07-8d54-42fa-b0e6-1f27a95d4c88",
          "threshold": 10,
          "name": "Free coffee",
          "icon": "coffee",
          "state": "locked",
          "translations": { "en": "Free coffee", "es": "Café gratis" }
        }
      ]
    }
  }
}

matchedOn is one of "serial" | "memberId" | "phone" | "email" and echoes which branch answered. customer.name is nullable — a customer can exist with no name. progress.stampsSinceLastRedemption sums stamps.count, so a single bulk stamp row of 5 counts as 5, not 1; a customer who has never redeemed gets their all-time total. That figure is the one to compare against a card's stampsRequired.

progress.rewards, rewardReady and rewardsReadyCount are the same shape the /stamps response returns — see the rewards notes there. A till that wants to greet the customer with "you have a free coffee waiting" can do it from this call alone, before stamping anything.

The response carries no email, phone or address. You already sent us whichever identifier you hold; echoing more back is a data-leak surface with no upside.

Response 200 — no match

{ "ok": true, "matched": false }

A miss is 200, not 404. A connector asking "do you know this shopper?" is not making an error when the answer is no. Build your error handling around the matched boolean, not around the status code — treating this as a failure means every non-member checkout looks like an outage. Note there is no matchedOn key at all in this body; do not require it.

A phone with fewer than 7 digits also lands here: it is a miss, not a validation error.

Response 200 — ambiguous

{ "ok": true, "matched": false, "reason": "ambiguous" }

Two or more customers at this merchant fit the identifier. We never guess between candidates — picking "the most recently seen" would silently award one shopper's loyalty to another, and the till has a cheap way out: ask for the pass QR (serial) or the printed membership number (memberId). Both resolve through unique indexes and cannot be ambiguous. Retrying the same phone or email will return ambiguous forever.

Only the phone and email branches can produce this. serial and memberId resolve through unique-indexed columns and structurally cannot return more than one row.

Phone matching, precisely

The input is reduced to digits (+34 600-11 22 33 → 34600112233), and the stored phone has spaces, hyphens and parentheses removed before a suffix comparison. So a locally-typed 600112233 finds a customer stored as +34 600 11 22 33, which is the whole point: formatting never decides the result.

The 7-digit floor is higher than the one behind the merchant's own customer-search box, and intentionally so. That search feeds a list a human picks from, where a few false positives cost nothing. This endpoint resolves to a single identity with nobody in the loop, so it has to hold up against ordinary collisions inside one merchant's customer list. Do not assume the two behave the same way.

Two caveats worth designing around:

  • Spaces, hyphens and parentheses are stripped from the stored number, but other separators are not — a number stored as 600.11.22.33 will not match. Numbers captured through our own enrolment flow are unaffected; data imported from elsewhere may not be.
  • A suffix match means a short-but-valid 7-digit number can collide with a longer one ending in the same digits. That collision surfaces as ambiguous, not as a wrong answer.

Errors

Statuserror codeWhen
400invalid_requestZero identifiers supplied, or more than one. Blank/whitespace fields do not count as supplied.
400FST_ERR_VALIDATIONBody failed the schema: a field over its maxLength, a non-string value, a malformed JSON body. Carries a human-readable message.
404no_merchantThe key's merchant row is missing or soft-deleted. Effectively terminal — stop using the key. Same code and meaning as on /stamps.

Plus the shared auth errors below.

Side effects: none, other than the throttled lastUsedAt touch on the API key.

Two scoping notes. Every branch filters on the key's merchant and on customers.deletedAt IS NULL, so a key can never resolve another merchant's customer, and a soft-deleted customer is a miss. But the serial branch does not check whether the pass itself was revoked or deleted, and the memberId branch does not check the membership's status — a serial from a replaced pass, or a member id on a revoked membership, still resolves to its (live) customer. If your flow depends on the pass itself being current, confirm that separately rather than inferring it from a successful match.


POST /api/v1/stamps

Award stamps — or points for a sale amount — to a customer, against the merchant's active stamp-type card. The write every connector exists to perform.

Auth: API key (stamps:write) Plan gate: blocked when the merchant's plan is cancelled (402 upgrade_required)

Request

ParamInTypeRequiredNotes
customerIdbodystring (≥8)YesFrom /customers/match, or stored from a previous match.
countbodyinteger 1–10NoDefaults to 1. Stamps cards only — sending it to a points card is 400 count_not_accepted.
amountCentsbodyinteger 0–100000000ConditionalThe sale total in minor units. Required on a points card; sending it to a stamps card is 400 amount_not_accepted.
notebodystring (≤400)NoFree text shown in the merchant's activity feed — a ticket number reads well here.
externalRefbodystring 1–120No but strongly recommendedYour immutable id for this sale. The idempotency anchor; see below.
locationbodyobjectYesWhich shop. {"storeId":"…"} or {"locationRef":"…"}, exactly one. See Locations.

There is no Idempotency-Key header. Idempotency is carried by externalRef in the body.

curl -sS -X POST https://api.walletloyaltycard.com/api/v1/stamps \
  -H 'Authorization: Bearer wlk_9Qb2fT1hR7sVn0kZxA4cLpE6yWuMdG3iJoN8rB5tS2Y' \
  -H 'Content-Type: application/json' \
  -d '{
        "customerId": "3ac9f5d2-8b71-4e6c-9a02-71d4e8b3c159",
        "count": 1,
        "note": "Ticket #A72F",
        "externalRef": "sq:pmt_01JT8Q4W2K",
        "location": { "locationRef": "oslo-03" }
      }'

Stamps cards vs. points cards. The merchant's card is one or the other, and your connector does not choose:

  • Stamps card (rewardMode: "stamps") — the award is count, defaulting to 1. Sending amountCents to a stamps card is 400 amount_not_accepted.
  • Points card (rewardMode: "spend") — the award is computed server-side from amountCents as floor(amountCents / spendStepCents) × pointsPerStep, in whole blocks: with a "5 points per €10" rule, a €27 sale earns 10 points, not 13.5. Any count you send is ignored. Omitting amountCents is 400 amount_required. A sale smaller than one whole block earns 0 points and still writes a stamp row with count: 0 — the amount is recorded so the merchant's revenue figures stay complete. Because points come from the amount, the awarded count here is not bounded by 10. Sending count to a points card is 400 count_not_accepted.

The mismatched field is rejected, not ignored. Send the field that belongs to the other card type and the call fails outright. This is deliberate: a connector pointed at the wrong card type would otherwise award 1 stamp where points were intended — the wrong amount of money, returned as a 201, with nothing in the response to notice. Failing the first sale makes the misconfiguration obvious instead of silently under-rewarding every customer.

The client never computes a point total; only the server does. If you need to preview the award before submitting, you cannot — read the result back off the response.

Response 201

{
  "ok": true,
  "stamp": {
    "id": "e4c07b91-3f28-4d5a-b6e1-9082ac37f45d",
    "customerId": "3ac9f5d2-8b71-4e6c-9a02-71d4e8b3c159",
    "count": 1,
    "note": "Ticket #A72F",
    "externalRef": "sq:pmt_01JT8Q4W2K",
    "createdAt": "2026-08-23T11:53:44.000Z"
  },
  "progress": {
    "stampsSinceRedemption": 8,
    "stampsRequired": 10,
    "rewardReady": false,
    "rewardsReadyCount": 0,
    "rewards": [
      {
        "id": "a1f4c7d9-0b26-4e83-9c15-7d802fa4e631",
        "threshold": 5,
        "name": "Free pastry",
        "icon": "croissant",
        "state": "redeemed",
        "translations": { "en": "Free pastry", "es": "Bollo gratis" }
      },
      {
        "id": "c93b1e07-8d54-42fa-b0e6-1f27a95d4c88",
        "threshold": 10,
        "name": "Free coffee",
        "icon": "coffee",
        "state": "locked",
        "translations": { "en": "Free coffee", "es": "Café gratis" }
      }
    ]
  },
  "card": {
    "id": "5d2b8e13-7a94-4c60-8f2d-3e1b6a07c948",
    "stampsRequired": 10,
    "rewardDescription": "Free coffee"
  }
}

The stamp block is a projection rather than the full stored record — it carries what a connector needs to print a receipt line and nothing more. stamp.externalRef echoes back the value you sent, which is what you should reconcile against.

progress.stampsRequired doubles as the points goal on a points card. rewardReady is simply stampsSinceRedemption >= stampsRequired; it means the customer has earned the reward, not that anything has been redeemed. To hand it over, call POST /api/v1/redemptions — or let the merchant redeem it in their own app. Awarding and redeeming are separate calls; stamping never redeems.

A card can carry several rewards, not just one. progress.rewards lists every reward on the card with its state for this customer — locked, ready or redeemed — and rewardsReadyCount is how many are ready right now. A merchant might set a free pastry at 5 stamps and a free coffee at 10; a receipt line that only ever mentions card.rewardDescription will silently under-report what the customer has earned.

  • threshold is the stamp or point count that unlocks that reward.
  • translations maps locale → reward name (en, da, de, es, fr, it, nl, no, sv, pl, pt, ar). Use it if your till renders in the shopper's language; name is the merchant-language name and is always present.
  • icon may be null.

card.rewardDescription remains the single headline reward and is kept for connectors written before multiple rewards existed. New integrations should read progress.rewards.

Errors

Statuserror codeWhen
404no_merchantThe key's merchant row is missing or soft-deleted. Effectively terminal — stop using the key.
404customer_not_foundNo non-deleted customer with that id under this merchant. Re-run /customers/match.
409no_active_cardThe merchant has no active, non-deleted stamp-type card template. The merchant must fix this in their dashboard; retrying will not help.
409external_ref_conflictThis externalRef was already used for a different customer. See below.
400amount_requiredPoints card and no amountCents.
400amount_not_acceptedamountCents sent to a stamps card. Drop the field.
400count_not_acceptedcount sent to a points card. Drop the field; the award is computed from amountCents.
400FST_ERR_VALIDATIONSchema failure: count outside 1–10, note over 400 chars, externalRef empty or over 120 chars, customerId under 8 chars, amountCents out of range or non-integer. Carries a message.

Plus the shared auth and plan errors below — including the 402 that applies to every endpoint on this surface.

Side effects (on a 201 only — a duplicate or a conflict writes nothing): a stamps row with source: "pos", staffUserId: null and storeId: null; customers.totalStamps += count, lastSeenAt bumped and firstSeenAt backfilled if it was null; an audit row (stamp.issue); a fire-and-forget wallet re-sync pushing the new count to the customer's Apple/Google pass. If this award crosses the reward threshold (computed before vs. after), two more fire-and-forget effects run: a Google Wallet "reward ready" banner and the merchant's own reward-ready automation. All of those are best-effort — if one fails, it is swallowed and your request still succeeds.

storeId is always null. There is no way to attribute a partner stamp to one of the merchant's locations — stamps.store_id is a foreign key into our own stores table, and a POS's till id is not one of those values.


POST /api/v1/prepaid/sales

Sell a prepaid pack — a 10-coffee card, a 5-wash bundle. Issues the card and hands you a claim URL for the buyer. No customerId is involved: a prepaid card is claimed by whoever opens the link, and the buyer becomes known to us only at that point.

Auth: API key (prepaid:write)

Request

{
  "templateId": "8c1f0a52-6d3b-4e97-b214-5f7a0c9d13e6",
  "externalRef": "sq:pmt_01JT8Q4W2K",
  "buyerEmail": "[email protected]",
  "location": { "locationRef": "oslo-03" }
}
FieldRequiredNotes
templateIdyesThe merchant's prepaid card design. The pack size lives on the template, not in this request — a merchant selling 5, 10 and 20-packs has three templates, and you pass the id of the one they bought. Ask the merchant for the ids; there is no endpoint that lists them.
externalRefyesRequired here, unlike the optional one on /stamps — a machine caller selling something must be idempotent. 1–120 chars; blank or whitespace-only is 400 invalid_request.
buyerEmailnoUp to 254 chars. If you send it, we can reach the buyer with their claim link; if you omit it, delivering the claimUrl is entirely your job.
locationyesWhich shop sold the pack. See Locations.

Response — 201 (or 200 with duplicate: true on a replayed externalRef)

{
  "ok": true,
  "card": {
    "id": "b71e3d08-52a9-4f6c-9d31-0a84c7e2f5b9",
    "status": "pending",
    "creditsTotal": 10,
    "claimUrl": "https://walletloyaltycard.com/p/b71e3d08529a4f6c"
  }
}

status is the card's own vocabulary, not the collapsed set the claim page shows: pending = issued but not yet claimed, active = claimed with credits left, spent = claimed and empty, revoked = cancelled. A fresh sale is always pending.

claimUrl is the deliverable. Print it on the receipt, text it, email it — if you drop it and sent no buyerEmail, the buyer has no route to the card they paid for.

Errors

Statuserror codeWhen
400invalid_requestexternalRef blank or whitespace-only.
409no_prepaid_templateNo active, non-deleted prepaid template with that id under this merchant. All four failure modes — wrong id, wrong merchant, wrong type, archived — deliberately collapse into this one code, so a key cannot probe which.
404no_merchantThe key's merchant row is missing or soft-deleted.

Plus the shared auth and plan errors.


POST /api/v1/prepaid/deduct

Spend exactly one credit off a prepaid card, identified by its wallet pass serial. This is the punch — call it once per redemption.

Auth: API key (prepaid:write)

There is no amount or quantity field, by design. Two coffees is two calls, each with its own externalRef.

Request

{
  "serial": "WLC-9F2A71C4",
  "externalRef": "sq:pmt_01JT8Q4W2M",
  "location": { "locationRef": "oslo-03" }
}
FieldRequiredNotes
serialyesThe pass serial, up to 128 chars — what your scanner reads off the customer's wallet pass.
externalRefyes1–120 chars; blank or whitespace-only is 400 invalid_request.
locationyesWhich shop punched the credit. See Locations.

Response — 201 (or 200 with duplicate: true on a replay)

{
  "ok": true,
  "remaining": 7,
  "total": 10,
  "spent": false
}

spent flips to true on the deduction that takes remaining to zero — that is your cue to tell the operator the card is finished. On a replay, the figures describe the card's current state, not the state at the original deduction.

Errors

Statuserror codeWhen
400invalid_requestexternalRef blank or whitespace-only.
404pass_not_foundNo pass with that serial under this merchant.
404no_merchantThe key's merchant row is missing or soft-deleted.
409not_prepaidThe serial resolves to a pass that is not a prepaid card.
409card_not_claimedSold but never claimed, so there is nothing to spend yet. The buyer must open the claimUrl first.
409card_spentAlready at zero credits.
409insufficient_creditsNot enough credits left for this deduction.
409external_ref_conflictThis externalRef was already used against a different card. A connector bug — see Idempotency and retries.
410card_revokedThe merchant cancelled the card. Note the status: 410, not 409 — it is the only 410 on this surface, and it is terminal.

Plus the shared auth and plan errors.

GET /api/v1/customers/:customerId/rewards

What this customer has earned, and which rewards are ready to hand over.

Auth: API key (rewards:read)

No body, no location — this is a read.

curl -sS https://api.walletloyaltycard.com/api/v1/customers/3ac9f5d2-8b71-4e6c-9a02-71d4e8b3c159/rewards \
  -H 'Authorization: Bearer wlk_9Qb2fT1hR7sVn0kZxA4cLpE6yWuMdG3iJoN8rB5tS2Y'

Response — 200

{
  "ok": true,
  "customerId": "3ac9f5d2-8b71-4e6c-9a02-71d4e8b3c159",
  "card": { "id": "8c1f0a52-6d3b-4e97-b214-5f7a0c9d13e6", "stampsRequired": 10 },
  "progress": {
    "stampsSinceRedemption": 7,
    "rewardReady": true,
    "rewardsReadyCount": 1,
    "nextReward": { "id": "r3", "name": "Free coffee", "threshold": 10, "remaining": 3 },
    "rewards": [
      { "id": "r1", "name": "Cookie", "threshold": 3, "state": "redeemed" },
      { "id": "r2", "name": "Pastry", "threshold": 5, "state": "ready" },
      { "id": "r3", "name": "Free coffee", "threshold": 10, "state": "locked" }
    ]
  }
}

A card can carry several rewards at different thresholds. state is per customer: ready means they can take it now, redeemed means they already have in this cycle, locked means they have not reached it. rewardsReadyCount is how many are takeable right now — on a multi-reward card that can be more than one, which is why the redeem endpoint may ask you to name which.

Errors

Statuserror codeWhen
404customer_not_foundNo such customer under this merchant.
404no_merchantThe key's merchant row is missing or soft-deleted.
409no_active_cardThe merchant has no active loyalty card.

Plus the shared auth and plan errors.

POST /api/v1/redemptions

Hand a reward over and mark it used.

Auth: API key (rewards:write)

Request

{
  "customerId": "3ac9f5d2-8b71-4e6c-9a02-71d4e8b3c159",
  "rewardId": "r2",
  "externalRef": "sq:redeem_01JT8Q4W2P",
  "location": { "locationRef": "oslo-03" }
}
FieldRequiredNotes
customerIdyesFrom /customers/match.
rewardIdnoWhich reward. Omit it when exactly one is ready and we will take that one. With several ready, omitting it is 409 choose_reward and the response lists them — we will not pick, because handing over the wrong reward is not something a retry can undo.
externalRefyes1–120 chars. Required here, unlike on /stamps: a reward is real money and a retried redeem without a reference burns a second one.
locationyesWhich shop handed it over. See Locations.

Response — 201 (or 200 with duplicate: true on a replayed externalRef)

{
  "ok": true,
  "redemption": {
    "id": "f10c7b93-2e45-4a81-bb60-9d3e5c7a1f28",
    "customerId": "3ac9f5d2-8b71-4e6c-9a02-71d4e8b3c159",
    "rewardId": "r2",
    "rewardName": "Pastry",
    "threshold": 5,
    "externalRef": "sq:redeem_01JT8Q4W2P",
    "createdAt": "2026-09-14T12:04:11.000Z"
  },
  "progress": {
    "remaining": 2,
    "resetsCycle": false,
    "forfeited": 0,
    "rewardReady": false,
    "rewardsReadyCount": 0,
    "nextReward": { "id": "r3", "name": "Free coffee", "threshold": 10, "remaining": 8 },
    "rewards": []
  }
}

Three fields in progress decide what you tell the customer:

  • resetsCycle — whether the card started over. Only the top reward on a stamps card does that; a mid-card milestone leaves their count alone. On a points card every redemption resets, and the surplus carries: redeeming a 100-point reward on a 230-point balance leaves 130.
  • remaining — what they have left afterwards, already accounting for the carry.
  • forfeited — milestones they had ready and just lost because the card reset. If this is greater than zero, say so before they walk away.

rewardName and threshold are snapshots taken at redemption. If the merchant renames or deletes the reward later, a replayed call still reports what the customer actually got.

Errors

Statuserror codeWhen
400invalid_requestexternalRef blank or whitespace-only.
404customer_not_foundNo such customer under this merchant.
404reward_not_foundrewardId is not a reward on this card.
409no_active_cardThe merchant has no active loyalty card.
409not_enough_stampsNothing is ready. The body carries have and nextReward.
409choose_rewardSeveral rewards are ready; name one in rewardId. The body lists the ready ones.
409reward_already_redeemedThat reward is already gone in this cycle.

Plus the shared auth and plan errors.

Idempotency and retries

A POS retries on a flaky network. externalRef is what stops that retry from awarding twice. It works the same way on all four write endpoints — /stamps, /prepaid/sales, /prepaid/deduct and /redemptions — except that /stamps merely accepts it while the other three require it. The examples below use stamps; the mechanics are identical.

Send your own immutable identifier for the sale — the payment id, the ticket id, the order id. Not a per-attempt id: two attempts at the same sale must send the same externalRef, or the mechanism does nothing. Leading and trailing whitespace is trimmed, so " order-1" and "order-1" are the same reference. A blank or whitespace-only value is treated as absent.

Behind the unique index (merchant_id, external_ref), the stored value is prefixed — partner:<yourRef>. Two consequences you can rely on:

  • The prefixed form never appears in a response. You always get your own string back.
  • Deduplication survives a key rotation. The stored reference does not depend on which key submitted it, so a sale replayed under a newly issued key is still recognised as the same sale and awards once. You do not need to drain in-flight retries before rotating, and re-syncing a batch of already-sent tickets after a reconfiguration will not double-award them.

The reference must be unique across every connector the merchant runs, not just within yours. The reference space is per merchant, so if a merchant runs two integrations — a till and an online ordering system, say — and both send a bare counter like order-1, they will collide. Qualify it with something of your own: sq:pmt_01JT8Q4W2K, toast:ord-99812. A collision across two different customers is rejected outright with 409 external_ref_conflict rather than silently swallowed, so you will find out immediately, but qualifying the reference avoids the problem.

Retry of the same sale, same customer → 200 with duplicate: true:

{
  "ok": true,
  "duplicate": true,
  "stamp": {
    "id": "e4c07b91-3f28-4d5a-b6e1-9082ac37f45d",
    "customerId": "3ac9f5d2-8b71-4e6c-9a02-71d4e8b3c159",
    "count": 1,
    "note": "Ticket #A72F",
    "externalRef": "sq:pmt_01JT8Q4W2K",
    "createdAt": "2026-08-23T11:53:44.000Z"
  },
  "progress": {
    "stampsSinceRedemption": 8,
    "stampsRequired": 10,
    "rewardReady": false
  },
  "card": { "id": "5d2b8e13-7a94-4c60-8f2d-3e1b6a07c948", "stampsRequired": 10, "rewardDescription": "Free coffee" }
}

Note the status is 200, not 201, and duplicate: true is present. The stamp block describes the original stamp — its id, its count and its original createdAt — while progress is recomputed live, so it reflects everything that has happened since, not the state at the original award. Treat this as success: the sale was already credited. duplicate is absent on a genuine first write; do not require the field.

(progress is abbreviated in the example above — a real response also carries rewards and rewardsReadyCount, exactly as on a 201.)

Same reference, different customer → 409 external_ref_conflict:

{ "ok": false, "error": "external_ref_conflict" }

This is a connector bug, not a retry — one identifier was reused across two sales. Absorbing it as success would report OK for the first customer's stamp while the second customer silently got nothing, so it surfaces instead. Nothing was written. Fix the reference generation; do not retry as-is.

Because the stamp insert is the first statement in the write batch, a duplicate or a conflict aborts before the customer counter or the audit row would have changed. Neither path double-counts anything.

Errors

Shared across all six endpoints, and not repeated in the per-endpoint tables above.

Statuserror codeWhenWhat to do
401not_authenticatedNo Authorization header, a header that is not the Bearer scheme at all, or Bearer with nothing after it.Fix the request. Note this is not missing_api_key.
401invalid_api_keyA token that is not shaped like one of our keys (a session token, garbage, someone else's credential), or a well-shaped key that resolves to no record. The two are deliberately indistinguishable.Check the stored key; do not retry.
401api_key_revokedA known key that the merchant's owner turned off.Terminal. Ask the merchant to mint a new key — never retry, and never fall back to another credential.
402upgrade_requiredThe merchant is not on an active plan. See Plan requirement.Terminal for you. Do not retry. Show message to the operator; the merchant must subscribe or reactivate.
403insufficient_scopeThe key is valid but does not hold the scope this endpoint requires (customers:read for match, stamps:write for stamps, prepaid:write for both prepaid routes, rewards:read / rewards:write for the rewards pair).Terminal. The merchant must mint a key carrying the scopes you need.
400invalid_locationWrite endpoints. location named neither storeId nor locationRef, or named both.Fix the request. See Locations.
403location_not_entitledWrite endpoints. The location exists, but this key is not allowed to write for it.Terminal for this key. The merchant adds the location to the key (and pays for it).
404location_not_foundWrite endpoints. No active location of this merchant matches what you sent.Terminal. Check the code with the merchant; we never create a location for you.
409no_locations_configuredWrite endpoints. The merchant has no active locations at all.Terminal. A setup step the merchant must do in their dashboard.
429rate_limitedOver 120 requests in the key's current 60-second window.Back off on your own clock; there is no Retry-After.
403ip_blockedPlatform-wide: the caller's IP is in our blocklist. Enforced before routing, on every request to the whole API.Not client-recoverable. Contact us.

The 402 body carries more than the code:

{
  "ok": false,
  "error": "upgrade_required",
  "reason": "plan_required",
  "currentPlan": "free",
  "requiredPlan": ["preview", "active"],
  "message": "The partner API requires an active plan. This merchant is on the free tier and must subscribe before the API will accept requests."
}

reason is plan_required when the merchant has never subscribed and subscription_cancelled when they had a plan and lost it. message is written to be shown to a human — an operator at a till, or your own support queue — and is phrased in the third person because the reader is not the merchant.

Order of checks, which decides what you see when more than one thing is wrong: revocation, then plan, then scope. So a revoked key on an unpaid merchant reports api_key_revoked, and a valid key on an unpaid merchant reports upgrade_required even if it also lacks the scope. This is deliberate — a partner who has been cut off learns nothing about what the key used to be allowed to do, and an unpaid merchant's key does not disclose its scopes.

Every code above is specific to this surface. Branch on the error string, never on the status code alone — several statuses carry more than one code, and they call for different handling.

Support

Something unclear, or behaving differently from this page? Email [email protected] with the endpoint, the request body (minus the key) and the response you got. If you are evaluating an integration and want to talk through the fit first, the same address reaches us.

Machine-readable spec: /openapi.json — OpenAPI 3.1, generated from the running service, importable into Postman or a codegen toolchain.

Build with us

Ready when your till is

The merchant makes the key, on any active plan, the 14-day trial included. After that it is €19 per location per month with a 30-day money-back guarantee. Or book a call and we will walk your team through it.