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.

HalfWho callsCredentialWhat it does
WriteYour backendS2S token + funtrips/wallet.writeAwards and spends tokens for any customer
ReadYour storefrontThe customer's end-user tokenShows 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.

award and spend are the whole vocabulary. Send exactly one of them.

There is no separate reversal operation: an award is how you undo a spend, and a spend is
how you undo an award. Tie the pair together with your own reason_code and meta so 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:

  1. 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.
  2. A spend is not weighed against the balance when it is accepted. One that exceeds the balance is acknowledged with 202 and then settles as FAILED. If your flow depends on the spend succeeding, read the balance first, or consume loyalty.LedgerEntryFailed.
  3. Failures surface as events, not as HTTP errors. loyalty.LedgerEntryBooked, loyalty.LedgerEntryFailed and loyalty.LedgerBalanceChanged are 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 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:

tokens has no unit

A 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:

  1. POST /session with their external_user_id and a short ttl_seconds.
  2. POST /session/token to exchange the code for an end-user token.
  3. Call GET /me/loyalty/ledger/balance with 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_idOrigin
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>:reversalA refund of a checkout discount
A bare ksuidAn entry you created through the admin endpoint
📘

Refunds arrive as awards.

When a checkout is reversed, the burned tokens are credited back as an award entry whose id
is the original entry id plus :reversal, carrying reason_code: CHECKOUT_DISCOUNT_REVERSAL and
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
    award posted 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.write provisioned on your S2S client — it is separate from funtrips/session.create
  • token_unit read 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_code vocabulary agreed before go-live
  • loyalty.LedgerEntryFailed consumed, or balances checked before posting a spend
  • Every write sends type as either award or spend
  • Storefront treats a 404 on the balance read as zero

Did this page help you?