What this API is for
The POS owns the ticket and the till. This API 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.
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 customers, redemptions, listing
customers or cards, key introspection, and outgoing webhooks. Nothing here polls or pushes —
every interaction is a request you make. Customer 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.
Integration flow
The whole connector, end to end:
- Once, at setup. The merchant's owner signs in to the dashboard and mints a key (see Getting a key). Your integration stores the plaintext — it is shown once and is unrecoverable afterwards.
- 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, and with 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, pluscard.rewardDescription. That is enough to print a receipt line ("7 of 10 — next free coffee at 10") with no second round trip. WhenrewardReadyistrue, the customer redeems in the merchant's own app or at the counter; there is no partner redemption endpoint.
A connector that only ever stamps known customers can skip step 2 if it already stores our
customerId. Nothing else in the flow is optional.
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. Those two are the only scopes the API accepts; anything else is rejected at the schema level. |
| 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. Both endpoints arePOSTwith 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
}
}
}
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.
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. |
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"
}'
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
},
"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. There is no partner redemption endpoint —
the merchant redeems in their own app.
Errors
| Status | error code | When |
|---|---|---|
| 402 | upgrade_required | Merchant's plan is cancelled. Body also carries reason: "subscription_cancelled" and a human-readable message, byte-identical to the first-party scan flow. Do not retry; the merchant must reactivate. |
| 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 errors below. The 402 body:
{
"ok": false,
"error": "upgrade_required",
"reason": "subscription_cancelled",
"message": "Your subscription has ended — reactivate to resume stamping new visits."
}
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.
Idempotency and retries
A POS retries on a flaky network. externalRef is what stops that retry from awarding twice.
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.
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 both 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. |
| 403 | insufficient_scope | The key is valid but does not hold the scope this endpoint requires (customers:read for match, stamps:write for stamps). | Terminal. The merchant must mint a key with both scopes. |
| 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. |
Revocation is checked before scope, deliberately: a revoked, out-of-scope key reports
api_key_revoked and never insufficient_scope, so a partner who has been cut off learns nothing
about what the key used to be allowed to do.
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.