€19 / MONTH · PER LOCATION
500 CUSTOMERS INCLUDED
NO APP REQUIRED
APPLE & GOOGLE WALLET
12 LANGUAGES
30-DAY REFUND
SET UP IN 15 MINUTES
CANCEL ANY TIME

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

The POS owns the ticket and the till. This API 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.

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:

  1. 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.
  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, and with 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 card.rewardDescription. That is enough to print a receipt line ("7 of 10 — next free coffee at 10") with no second round trip. When rewardReady is true, 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.

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. Those two are the only scopes the API accepts; anything else is rejected at the schema level.
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. Both endpoints are 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
    }
  }
}

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 3334600112233), 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.

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 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
  },
  "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

Statuserror codeWhen
402upgrade_requiredMerchant'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.
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 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.

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.
403insufficient_scopeThe 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.
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.

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.