Admin — create ledger entry

ADMIN

Adds a loyalty token award, spend, or reversal for any user. Reserved for trusted
S2S services managing the loyalty programme.

Events fired

EventTriggerDescription
loyalty.LedgerEntryBookedasyncFired by the balance worker when the entry is successfully booked.
loyalty.LedgerEntryFailedasyncFired by the balance worker when booking the entry fails.
loyalty.LedgerBalanceChangedasyncFired by the balance worker after a successful booking that changes the user's balance.
Recent Requests
Log in to see full request history
TimeStatusUser Agent
Retrieving recent requests…
LoadingLoading…
📘

Booking is asynchronous — a 202 means accepted, not applied. The entry lands as PENDING and a background worker books it.

Eventsloyalty.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

CredentialS2S token (client_credentials)
Scopefuntrips/wallet.write
CampaignResolved 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"]
  }'
FieldRequiredNotes
typeYesaward to give tokens, spend to take them
tokensYesAt least 1. Always positive — type carries the direction
reasonNoHuman-readable, surfaced to the customer in their history
reason_codeNoMachine-readable. Use a stable vocabulary — this is what you report on later
sourceNoWhich of your systems produced it, e.g. pos, crm, batch
reported_atNoWhen it actually happened. Defaults to now, may be backdated
metaNoArray of strings. Your own references

tokens has no unit here either

500 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.

📘

tokens must be at least 1. When your rules compute a zero award, skip the call rather than

writing an entry that moves nothing.

reported_at may predate the call

Send 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

{
  "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 202 will 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 the loyalty.LedgerBalanceChanged event 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

The 202 only means the entry was valid and stored. Booking happens later and can still fail:

What happensResult
Award books normallystatus: BOOKED, balance increases, loyalty.LedgerBalanceChanged fires
Spend exceeds the balanceBooking fails — the entry ends FAILED and the balance does not move
Worker errorstatus: 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

Send exactly award or spend. No other value moves a balance.

  • To give tokens — a promotion, a goodwill gesture, giving back tokens the customer spent
    elsewhere — post an award.
  • To take tokens — a clawback, a correction, a redemption that happened in your systems —
    post a spend.

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.

OutcomeMeaning
202Accepted and stored
208Already processed. The original entry is returned; nothing was written twice. Treat as success
409 + Retry-AfterThe original is still in flight. Retry with the same key
409, no Retry-AfterSame 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

StatustypeMeaningWhat to do
400wallet-entry-validation-failedtokens not greater than 0, missing type, or a malformed fieldFix the request
400Idempotency-Key missing or malformedSend one
401S2S token missing, expired or invalidRe-fetch the token, retry once
403Token lacks funtrips/wallet.writeProvisioning — contact us
409Idempotency collisionSee above
500wallet-entry-save-failedThe entry could not be storedRetry with the same key
500Unexpected faultRetry 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

Path Params
string
required

The user's external identifier.

Body Params
string
enum
required
Allowed:
integer
required
≥ 0
string
string
string
date-time
meta
array of strings
meta
Headers
uuid
required

Client-generated UUID. Identical keys within the TTL window return the cached response.

string
enum
Defaults to application/json

Generated from available response content types

Allowed:
Responses

Language
Credentials
OAuth2
Missing 1 required scope
URL
LoadingLoading…
Response
Click Try It! to start a request and see the response here! Or choose an example:
application/json
application/problem+json