ADMIN
Adds a loyalty token award, spend, or reversal for any user. Reserved for trusted
S2S services managing the loyalty programme.
Events fired
| Event | Trigger | Description |
|---|---|---|
loyalty.LedgerEntryBooked | async | Fired by the balance worker when the entry is successfully booked. |
loyalty.LedgerEntryFailed | async | Fired by the balance worker when booking the entry fails. |
loyalty.LedgerBalanceChanged | async | Fired by the balance worker after a successful booking that changes the user's balance. |
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
Booking is asynchronous — a202means accepted, not applied. The entry lands asPENDINGand a background worker books it.Events —
loyalty.LedgerEntryBooked,loyalty.LedgerEntryFailed,loyalty.LedgerBalanceChanged(all async, from the worker)
Why this endpoint exists
This is the retailer's write access to the loyalty wallet. Everything else in the wallet surface reads; this is the one endpoint that moves a balance.
You call it from your backend when something in your systems should change a customer's token balance — they shopped in-store, completed a promotion, earned a monthly bonus, or you are correcting a mistake. The platform cannot know about any of that, so the ledger has to be told.
It sits under /admin/ because it can move any customer's balance, and that is not something a browser should be able to do. But it is an S2S endpoint your credentials can hold: ask us to provision funtrips/wallet.write on your client and it becomes part of your ordinary backend integration. See Loyalty wallet integration for how it fits with the read side.
Authorisation and scoping
| Credential | S2S token (client_credentials) |
| Scope | funtrips/wallet.write |
| Campaign | Resolved from the token's campaign claim |
There is no campaign parameter, and you cannot set one. The campaign comes from your client_id, so a token can only ever write to its own campaign's ledger. If you run several campaigns you hold several client ids.
external_user_id in the path is your identifier for the customer — the same value you passed to POST /session. The platform stores it verbatim and there is no separate user id to look up.
Request
curl -X POST "https://api.acc.funtrips.io/v2/admin/loyalty/ledgers/customer-4815162342/entries" \
-H "Authorization: Bearer $S2S_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"type": "award",
"tokens": 500,
"reason": "In-store purchase bonus",
"reason_code": "INSTORE_BONUS",
"source": "pos",
"reported_at": "2026-09-02T16:41:00Z",
"meta": ["receipt:88213", "store:412"]
}'| Field | Required | Notes |
|---|---|---|
type | Yes | award to give tokens, spend to take them |
tokens | Yes | At least 1. Always positive — type carries the direction |
reason | No | Human-readable, surfaced to the customer in their history |
reason_code | No | Machine-readable. Use a stable vocabulary — this is what you report on later |
source | No | Which of your systems produced it, e.g. pos, crm, batch |
reported_at | No | When it actually happened. Defaults to now, may be backdated |
meta | No | Array of strings. Your own references |
tokens has no unit here either
tokens has no unit here either500 is €5.00 on a campaign whose token_unit is cents, and 500 points when it is points. Read the unit from GET /campaign/config and make sure whatever computes your award amounts agrees with it. Getting this wrong at write time is worse than at display time — you will have booked the wrong value into a ledger customers can see.
tokensmust be at least1. When your rules compute a zero award, skip the call rather thanwriting an entry that moves nothing.
reported_at may predate the call
reported_at may predate the callSend the real event time when you are catching up — an overnight batch of yesterday's in-store purchases should carry yesterday's timestamps. The ledger keeps report_date separately from creation_date precisely so a backfill reads correctly to the customer.
Note that this makes a history page sorted by ledger order not necessarily sorted by report_date. See GET /me/loyalty/ledger/entries.
202 means accepted, not applied
202 means accepted, not applied{
"data": {
"entry_id": "2gK8xQ1mZp4vY7nB3tL6wZ9hJ",
"campaign_id": "3c1e5a90-7b2d-4f61-9a83-1d4e7f2b8c05",
"external_user_id": "customer-4815162342",
"type": "award",
"tokens": 500,
"status": "PENDING",
"balance_after": null,
"reason": "In-store purchase bonus",
"reason_code": "INSTORE_BONUS",
"source": "pos",
"report_date": "2026-09-02T16:41:00Z",
"creation_date": "2026-09-03T09:12:04Z",
"processed_date": null
},
"meta": { "correlation_id": "…" }
}The entry is written as PENDING and a background worker books it. Until it does, the balance is unchanged.
Do not award tokens and immediately read the balance expecting the new value.A read straight after a
202will usually return the old balance. If your flow shows "you just earned €5, your balance is now X", either compute X yourself from the award, or wait for theloyalty.LedgerBalanceChangedevent rather than polling the balance endpoint in a tight loop.
The entry_id is a ksuid — opaque, not a UUID, and not the deterministic prefix:key form that platform-generated entries use. Store it; it is how you reconcile your side against the ledger.
balance_after is reserved for future use and is not populated. Read the balance from GET /me/loyalty/ledger/balance instead of expecting it here.
Booking can fail after the 202
202The 202 only means the entry was valid and stored. Booking happens later and can still fail:
| What happens | Result |
|---|---|
| Award books normally | status: BOOKED, balance increases, loyalty.LedgerBalanceChanged fires |
| Spend exceeds the balance | Booking fails — the entry ends FAILED and the balance does not move |
| Worker error | status: FAILED, loyalty.LedgerEntryFailed fires |
A spend is accepted without being weighed against the balance, so one that exceeds it is
acknowledged with 202 and then settles as FAILED. If your flow depends on the spend
succeeding, read the balance first and check it yourself, or consume loyalty.LedgerEntryFailed.
A 202 is not permission to treat the tokens as spent.
award and spend are the whole vocabulary
award and spend are the whole vocabulary
Send exactlyawardorspend. No other value moves a balance.
- To give tokens — a promotion, a goodwill gesture, giving back tokens the customer spent
elsewhere — post anaward. - To take tokens — a clawback, a correction, a redemption that happened in your systems —
post aspend.
There is no separate reversal operation on this endpoint: an award is how you undo a spend, and a
spend is how you undo an award. Tie the two together with your own reason_code and meta so the
pair is recognisable at reconciliation.
Platform-generated refunds of checkout discounts also arrive as award entries, with their own
reason code — see GET /me/loyalty/ledger/entries.
Idempotency
Idempotency-Key is required; omitting it is a 400. Keys are remembered for 24 hours.
This matters more here than almost anywhere else in the API: a retried award without a key is free money, and a retried spend takes tokens twice. Generate one UUID per logical movement — this receipt earning this bonus — and reuse it for every retry of that movement.
| Outcome | Meaning |
|---|---|
202 | Accepted and stored |
208 | Already processed. The original entry is returned; nothing was written twice. Treat as success |
409 + Retry-After | The original is still in flight. Retry with the same key |
409, no Retry-After | Same key, different body. Generate a new key |
For a batch job, derive the key deterministically from your own record — a receipt id, a row id — rather than generating a fresh UUID per run. Then re-running the batch is inherently safe.
Errors
| Status | type | Meaning | What to do |
|---|---|---|---|
400 | wallet-entry-validation-failed | tokens not greater than 0, missing type, or a malformed field | Fix the request |
400 | — | Idempotency-Key missing or malformed | Send one |
401 | — | S2S token missing, expired or invalid | Re-fetch the token, retry once |
403 | — | Token lacks funtrips/wallet.write | Provisioning — contact us |
409 | — | Idempotency collision | See above |
500 | wallet-entry-save-failed | The entry could not be stored | Retry with the same key |
500 | — | Unexpected fault | Retry with the same key |
A 403 here is the common first-integration failure: the S2S credential works for session creation but was never granted funtrips/wallet.write. The two scopes are provisioned independently.
Related
- Loyalty wallet integration — how the write and read sides fit together
GET /me/loyalty/ledger/balance— the customer's balance, read with their own sessionGET /me/loyalty/ledger/entries— the history your entries appear inPOST /me/loyalty/ledger/sync— reconciling against a partner's counter- Idempotency
