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:
- Who is this? —
POST /api/v1/customers/matchturns an identifier the till already holds (a scanned pass, a membership number, a phone number, an email) into ourcustomerId. - Give them the points. —
POST /api/v1/stampsawards stamps, or points for a sale amount, against thatcustomerId.
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:
- Sell a pack. —
POST /api/v1/prepaid/salesissues a card and returns a claim URL you send to the buyer. - Punch one out. —
POST /api/v1/prepaid/deductspends exactly one credit off a card by its pass serial.
Rewards — handing over what the customer earned. Two endpoints, two more scopes:
- What have they earned? —
GET /api/v1/customers/:customerId/rewardslists every reward on the card with its state for this customer. - Give it to them. —
POST /api/v1/redemptionsmarks 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 plan | API |
|---|---|
active — paying | Works. |
preview — trial | Works. Note a trial ends, and the API stops with it unless they subscribe. |
past_due — paying, payment failed | Works. We do not cut off a live till over a failed card; our dunning handles it. |
free — never subscribed | 402 upgrade_required, reason: "plan_required". |
cancelled — lapsed | 402 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
402as a real runtime state, surface themessageto whoever is standing at the till, and do not retry. reasontells you who to call.subscription_cancelledmeans the merchant needs to reactivate a plan they had;plan_requiredmeans 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
storeIdis our uuid. Use it if your configuration can hold our identifiers.locationRefis 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 withoslo-03than 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.)
- 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. - At the point of sale, identify the shopper. Call
POST /api/v1/customers/matchwith exactly one identifier. Three outcomes, all200:matched: true→ you have acustomerId; go to step 3.matched: falsewith noreason→ 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.
- Award the points. Call
POST /api/v1/stampswith thecustomerId, thelocationthis till serves, and anexternalRefset to your own immutable id for that sale (payment id, ticket id, order id). That reference is the idempotency anchor — see Idempotency and retries. - Show the result. The response carries
progress.stampsSinceRedemption,progress.stampsRequiredandprogress.rewardReady, plusprogress.rewards— every reward on the card with its state — andcard.rewardDescription. That is enough to print a receipt line ("7 of 10 — next free coffee at 10") with no second round trip. - If a reward is ready, hand it over. Optional, and only if the till is where rewards get
given out. Call
POST /api/v1/redemptionswith a freshexternalRef— a different one from the sale in step 3 — and tell the customer whatprogress.forfeitedsays 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.
| Property | Value |
|---|---|
| Format | wlk_ + 32 CSPRNG bytes, base64url — 47 characters total, e.g. wlk_9Qb2fT1hR7sVn0kZxA4cLpE6yWuMdG3iJoN8rB5tS2Y |
| At rest | SHA-256 of the plaintext, plus the first 12 characters kept in clear for display. The plaintext is never stored and never logged. |
| Shown | Exactly once, in the create response. There is no "reveal" and no reissue — a lost key must be revoked and replaced. |
| Display mask | The merchant's dashboard lists a key by its first 12 characters, wlk_9Qb2fT1h…, so they can tell keys apart without exposing one. |
| Label | Required, 1–80 characters after trimming. Free text so keys are attributable — "Square connector", "Front till". |
| Scopes | At 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. |
| Locations | The 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. |
| Merchant | One. A key never spans merchants, and there is no platform-wide key. |
| Expiry | None. 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. |
| Revocation | Sets 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 isPOSTwith a JSON body. - The
Bearerscheme is matched case-insensitively; the token itself is not. Thewlk_prefix is what tells the middleware it is holding a machine key rather than a session token — a session token presented here is rejected asinvalid_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 onok, then onerror.
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/403takes precedence over a429. - No
Retry-Afterheader and noX-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.
| Param | In | Type | Required | Notes |
|---|---|---|---|---|
serial | body | string (≤128) | one of | The 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. |
memberId | body | string (≤64) | one of | Membership/access card member id, VIP-XXXXXXXX or ACC-XXXXXXXX. Trimmed, then matched exactly — case-sensitive. Unique platform-wide. |
phone | body | string (≤32) | one of | Compared as digits, not as a string. Suffix match. Minimum 7 digits. See below. |
email | body | string (≤254) | one of | Trimmed 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.33will 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
| Status | error code | When |
|---|---|---|
| 400 | invalid_request | Zero identifiers supplied, or more than one. Blank/whitespace fields do not count as supplied. |
| 400 | FST_ERR_VALIDATION | Body failed the schema: a field over its maxLength, a non-string value, a malformed JSON body. Carries a human-readable message. |
| 404 | no_merchant | The 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
| Param | In | Type | Required | Notes |
|---|---|---|---|---|
customerId | body | string (≥8) | Yes | From /customers/match, or stored from a previous match. |
count | body | integer 1–10 | No | Defaults to 1. Stamps cards only — sending it to a points card is 400 count_not_accepted. |
amountCents | body | integer 0–100000000 | Conditional | The sale total in minor units. Required on a points card; sending it to a stamps card is 400 amount_not_accepted. |
note | body | string (≤400) | No | Free text shown in the merchant's activity feed — a ticket number reads well here. |
externalRef | body | string 1–120 | No but strongly recommended | Your immutable id for this sale. The idempotency anchor; see below. |
location | body | object | Yes | Which 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 iscount, defaulting to 1. SendingamountCentsto a stamps card is400 amount_not_accepted. - Points card (
rewardMode: "spend") — the award is computed server-side fromamountCentsasfloor(amountCents / spendStepCents) × pointsPerStep, in whole blocks: with a "5 points per €10" rule, a €27 sale earns 10 points, not 13.5. Anycountyou send is ignored. OmittingamountCentsis400 amount_required. A sale smaller than one whole block earns 0 points and still writes a stamp row withcount: 0— the amount is recorded so the merchant's revenue figures stay complete. Because points come from the amount, the awardedcounthere is not bounded by 10. Sendingcountto a points card is400 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.
thresholdis the stamp or point count that unlocks that reward.translationsmaps 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;nameis the merchant-language name and is always present.iconmay benull.
card.rewardDescription remains the single headline reward and is kept for connectors written
before multiple rewards existed. New integrations should read progress.rewards.
Errors
| Status | error code | When |
|---|---|---|
| 404 | no_merchant | The key's merchant row is missing or soft-deleted. Effectively terminal — stop using the key. |
| 404 | customer_not_found | No non-deleted customer with that id under this merchant. Re-run /customers/match. |
| 409 | no_active_card | The merchant has no active, non-deleted stamp-type card template. The merchant must fix this in their dashboard; retrying will not help. |
| 409 | external_ref_conflict | This externalRef was already used for a different customer. See below. |
| 400 | amount_required | Points card and no amountCents. |
| 400 | amount_not_accepted | amountCents sent to a stamps card. Drop the field. |
| 400 | count_not_accepted | count sent to a points card. Drop the field; the award is computed from amountCents. |
| 400 | FST_ERR_VALIDATION | Schema 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" }
}
| Field | Required | Notes |
|---|---|---|
templateId | yes | The 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. |
externalRef | yes | Required 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. |
buyerEmail | no | Up 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. |
location | yes | Which 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
| Status | error code | When |
|---|---|---|
| 400 | invalid_request | externalRef blank or whitespace-only. |
| 409 | no_prepaid_template | No 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. |
| 404 | no_merchant | The 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" }
}
| Field | Required | Notes |
|---|---|---|
serial | yes | The pass serial, up to 128 chars — what your scanner reads off the customer's wallet pass. |
externalRef | yes | 1–120 chars; blank or whitespace-only is 400 invalid_request. |
location | yes | Which 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
| Status | error code | When |
|---|---|---|
| 400 | invalid_request | externalRef blank or whitespace-only. |
| 404 | pass_not_found | No pass with that serial under this merchant. |
| 404 | no_merchant | The key's merchant row is missing or soft-deleted. |
| 409 | not_prepaid | The serial resolves to a pass that is not a prepaid card. |
| 409 | card_not_claimed | Sold but never claimed, so there is nothing to spend yet. The buyer must open the claimUrl first. |
| 409 | card_spent | Already at zero credits. |
| 409 | insufficient_credits | Not enough credits left for this deduction. |
| 409 | external_ref_conflict | This externalRef was already used against a different card. A connector bug — see Idempotency and retries. |
| 410 | card_revoked | The 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
| Status | error code | When |
|---|---|---|
| 404 | customer_not_found | No such customer under this merchant. |
| 404 | no_merchant | The key's merchant row is missing or soft-deleted. |
| 409 | no_active_card | The 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" }
}
| Field | Required | Notes |
|---|---|---|
customerId | yes | From /customers/match. |
rewardId | no | Which 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. |
externalRef | yes | 1–120 chars. Required here, unlike on /stamps: a reward is real money and a retried redeem without a reference burns a second one. |
location | yes | Which 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
| Status | error code | When |
|---|---|---|
| 400 | invalid_request | externalRef blank or whitespace-only. |
| 404 | customer_not_found | No such customer under this merchant. |
| 404 | reward_not_found | rewardId is not a reward on this card. |
| 409 | no_active_card | The merchant has no active loyalty card. |
| 409 | not_enough_stamps | Nothing is ready. The body carries have and nextReward. |
| 409 | choose_reward | Several rewards are ready; name one in rewardId. The body lists the ready ones. |
| 409 | reward_already_redeemed | That 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.
| Status | error code | When | What to do |
|---|---|---|---|
| 401 | not_authenticated | No 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. |
| 401 | invalid_api_key | A 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. |
| 401 | api_key_revoked | A 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. |
| 402 | upgrade_required | The 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. |
| 403 | insufficient_scope | The 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. |
| 400 | invalid_location | Write endpoints. location named neither storeId nor locationRef, or named both. | Fix the request. See Locations. |
| 403 | location_not_entitled | Write 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). |
| 404 | location_not_found | Write 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. |
| 409 | no_locations_configured | Write endpoints. The merchant has no active locations at all. | Terminal. A setup step the merchant must do in their dashboard. |
| 429 | rate_limited | Over 120 requests in the key's current 60-second window. | Back off on your own clock; there is no Retry-After. |
| 403 | ip_blocked | Platform-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.