Loyalty wallet integration
How a retailer writes token movements from its backend and reads balances in its storefront.
On campaigns whose discount_type is LOYALTY, customers hold a token balance that buys down the price of a basket. The wallet is a ledger: a balance is the sum of its booked entries, not a number someone sets.
An integration has two halves, and they use different credentials because they answer to different parties.
| Half | Who calls | Credential | What it does |
|---|---|---|---|
| Write | Your backend | S2S token + funtrips/wallet.write | Awards and spends tokens for any customer |
| Read | Your storefront | The customer's end-user token | Shows that customer their balance and history |
There is no third half. You never apply a discount yourself — when a checkout is created with an authenticated customer, the platform resolves their entitlement and spends from the wallet server-side.
sequenceDiagram
participant POS as Your systems
participant BE as Your backend
participant P as Funtrips API
participant W as Balance worker
participant FE as Your storefront
POS->>BE: Customer earned a bonus
BE->>P: POST /admin/loyalty/ledgers/{id}/entries (S2S)
P-->>BE: 202 — entry PENDING
P->>W: queued
W->>W: books entry → balance moves
Note over W: loyalty.LedgerBalanceChanged
FE->>P: GET /me/loyalty/ledger/balance (end-user token)
P-->>FE: current balance
FE->>P: POST /checkouts
P-->>FE: totals with a LOYALTY discount applied
Writing: the one endpoint that moves a balance
POST /admin/loyalty/ledgers/{external_user_id}/entries is your write access. It is under /admin/ because it can move any customer's balance — but it is an ordinary S2S call once we provision funtrips/wallet.write on your client.
Two token movements exist, and they are the entire vocabulary:
award— give tokens. Promotions, in-store earnings, goodwill, corrections upward.spend— take tokens. Clawbacks, corrections downward, redemptions that happen in your systems rather than in a Funtrips checkout.
tokens is always positive; type carries the direction.
awardandspendare the whole vocabulary. Send exactly one of them.There is no separate reversal operation: an
awardis how you undo aspend, and aspendis
how you undo anaward. Tie the pair together with your ownreason_codeandmetaso it is
recognisable at reconciliation.
Booking is asynchronous
A 202 means stored and valid, not applied. The entry lands PENDING and a worker books it a moment later.
Three consequences worth designing for:
- Reading the balance straight after a write usually returns the old value. If you show "you earned €5, your balance is now X", compute X locally or wait for
loyalty.LedgerBalanceChanged. Do not poll the balance endpoint in a tight loop. - A
spendis not weighed against the balance when it is accepted. One that exceeds the balance is acknowledged with202and then settles asFAILED. If your flow depends on the spend succeeding, read the balance first, or consumeloyalty.LedgerEntryFailed. - Failures surface as events, not as HTTP errors.
loyalty.LedgerEntryBooked,loyalty.LedgerEntryFailedandloyalty.LedgerBalanceChangedare the real completion signals.
Idempotency is not optional here
Idempotency-Key is required, and this is the endpoint where it protects real value: a retried award is free money, a retried spend takes tokens twice.
For batch work, derive the key from your own record — a receipt id, a row id, a (customer, period) pair — rather than generating a fresh UUID per run. Then re-running the batch is inherently safe, and a 208 on every row is exactly what a correct re-run looks like. Keys live 24 hours.
Give reason_code a stable vocabulary
reason_code a stable vocabularyreason is shown to the customer; reason_code is what you will group and reconcile on months later. Decide the codes up front (INSTORE_BONUS, SIGNUP_BONUS, GOODWILL, CLAWBACK) and keep them stable. source names the system that produced the entry, meta holds your own references.
Send reported_at when the movement actually happened. A nightly batch of yesterday's purchases should carry yesterday's timestamps — the ledger keeps report_date separate from creation_date for exactly this.
Reading: the storefront, with the customer's own session
Your storefront reads the wallet with the customer's end-user token, obtained through session exchange:
GET /me/loyalty/ledger/balance— the number to display. A404means the customer has never transacted; render it as zero, not as an error.GET /me/loyalty/ledger/entries— the history behind it. OnlyBOOKEDentries have moved the balance.POST /me/loyalty/ledger/sync— reconcile against a partner's counter, on campaigns where the retailer's own loyalty system is the source of truth.
tokens has no unit
tokens has no unitA balance of 1250 is €12.50 when the campaign's token_unit is cents and 1250 points when it is points. Read the unit from GET /campaign/config once at boot and keep it next to your formatter. The same integration can serve both kinds of campaign, so do not hardcode it — and make sure whatever computes your award amounts agrees with whatever displays them.
There is no server-side balance read
Wallet reads are scoped to the customer: both /me/* reads require that customer's own token, and
there is no server-to-server equivalent.
So when your backend needs a customer's balance — a statement email, a CRM sync, a support tool —
mint a session for that customer and read as them:
POST /sessionwith theirexternal_user_idand a shortttl_seconds.POST /session/tokento exchange the code for an end-user token.- Call
GET /me/loyalty/ledger/balancewith it.
That works because your S2S credential already authorises minting a session for any customer in
your campaign. It is more round trips than a direct read, so cache the result for the life of the
job rather than per row. If you need this at scale, talk to us before building around it.
Recognising platform-generated entries
Your entries are not the only ones in the ledger. The platform writes its own, and the entry_id prefix says which flow produced it:
entry_id | Origin |
|---|---|
voucher:<code> | A voucher redemption awarded tokens |
checkout-discount:<checkout_id> | Tokens spent on a checkout |
loyalty_sync:<…> | A partner balance sync corrected a drift |
<original_entry_id>:reversal | A refund of a checkout discount |
| A bare ksuid | An entry you created through the admin endpoint |
Refunds arrive as awards.When a checkout is reversed, the burned tokens are credited back as an
awardentry whose id
is the original entry id plus:reversal, carryingreason_code: CHECKOUT_DISCOUNT_REVERSALand
source: checkout_discount_reversal. Match on the reason code or the id suffix to label refunds
distinctly.
Partner-counter campaigns
On some campaigns the retailer's own loyalty system is authoritative and the platform's ledger is a mirror of it. There the local balance can lag, and POST /me/loyalty/ledger/sync reads the partner's counter, writes a sync entry for the delta, and returns the corrected balance.
Two things follow:
- How the platform reaches your system is agreed per integration — see
Connecting to your own systems. Sync may
require a credential your front-end supplies; that is settled when the integration is scoped. - Think before writing awards when your system is authoritative. If it owns the number, an
awardposted here may be corrected away by the next sync. Award in your own system and let
sync bring it across, unless we have agreed otherwise for your campaign.
Checklist
-
funtrips/wallet.writeprovisioned on your S2S client — it is separate fromfuntrips/session.create -
token_unitread from campaign config, used by both your award calculation and your formatter - Idempotency keys derived from your own records, not generated per run
- A stable
reason_codevocabulary agreed before go-live -
loyalty.LedgerEntryFailedconsumed, or balances checked before posting aspend - Every write sends
typeas eitherawardorspend - Storefront treats a
404on the balance read as zero
Updated 40 minutes ago
