curl, openssl and jq. Authentication covers where the credential comes from, what every request carries, and what to check when one is rejected.
Each step prints its status code first and then the values it captured, each labelled, so the last line you see is always the answer to the request you just sent.
Before you start
Export the base URL for this environment:1. Create a customer to work with
Every request below takes the id this one returns. The body carries the four required fields and nothing else; Onboard a customer is the close reading of this call and of the optional members it accepts.USER_ALREADY_EXISTS means that address is already a customer of your tenant, so pick another address, and a 400 with IDEMPOTENCY_KEY_REQUIRED means the write went out without X-Idempotency-Key, which every write on this page sends.
2. Grant XP
amount is a whole number of XP and has to be at least one. notes is optional and is kept against the entry.
IDEMPOTENCY_KEY_CONFLICT: a key is bound to the request it first carried.
What comes back is a receipt, not a balance. The response carries the transaction the credit wrote to the ledger, and that transaction is the evidence the grant was accepted. Treat it that way in your own code: if this call times out, read the transaction or use the same key again, and do not send a second earn.
The refusals here are about the customer rather than the amount. 404 with USER_NOT_FOUND means CUSTOMER_ID is not a customer of your tenant, which is also what a step 1 that failed and left it as null gives you. 400 with XP_USER_LOCKED means that customer’s XP is held, and 400 with USER_LOCKED means their account is not active.
3. Read the balance
The number a balance read answers is on-chain XP, and it changes when the chain work behind the grant is indexed rather than when the grant returns. So this reads until the credit shows up instead of asserting that it already has:200 balance=250, and GRANTED now holds the figure the next balance read has to move away from.
balance= with nothing after it means the loop stopped on a status that was not 200, and the status on that same line is the one to work from. 200 balance=0 means the loop ran out of attempts with the credit still not indexed. Re-run this block rather than the earn, which would grant a second time.
The balance comes back as a string. Parse it into a big integer type, or keep it as a string and convert at the edge, because a balance can outgrow what a floating point number holds exactly.
4. Create a reward
A reward is what a customer can spend the balance on. It takes a title and a cost, and the cost is what a redemption charges, so the request that spends it carries no amount of its own. Two fields decide whether the reward you are about to create can be spent at all, and both default against you:status. A create with no status stores the reward unpublished, and a redeem against an unpublished reward returns 400 with REWARD_INACTIVE. Sending "status":"published" here is what makes the next step work without a second request. Publishing is close to one-way, though: after it, is_active is the only field an update may change, so get the title and the cost right before you send this.
min_tier_id. Left out, the reward is open to every customer. Name a tier and a customer whose balance has not reached that tier’s threshold gets 403 with REWARD_TIER_LOCKED, and their balance is never compared against the cost at all. This walkthrough leaves it out so there is one refusal to reason about instead of two.
GET /gateway/rewards/{rewardId} if you want to see the stored cost and status rather than trust what you sent.
xp_cost is a whole number of at least one, sent unquoted. That is the opposite of the balance you just read: amounts come back as strings and go out as numbers.
5. Redeem it
A redemption is one customer spending one reward’s cost. The body names the two of them and nothing else:redemption_id on that transaction is how you reach the record the next step reads, so capture it here: the redeem is the only response that names it directly. Lose it and the list read below finds the record again, filtered by this customer and this reward.
Refusals arrive in one fixed sequence, which is worth knowing because it tells you which check a client tripped rather than leaving you to guess: the customer has to be yours, their XP has to be unlocked, their account has to be active, the reward has to be yours and published, then the tier gate, then the cost. Two more can come from the environment rather than from your request, 503 with XP_SERVICE_NOT_AVAILABLE and 404 with ONCHAIN_ASSET_NOT_FOUND, and both mean send it again unchanged.
One thing not to do here: issue two redeems for the same customer at once. The cost check reads the balance as it currently stands and reserves nothing, so both can be accepted against XP only one of them can spend, and the surplus one fails after the fact. Serialize a customer’s redeems.
6. Read the redemption back
200 redemption_status=initialized xp_spent=200 reward=Free coffee. The record names the reward it was made against, so a list of redemptions reads as spends on named rewards rather than as pairs of identifiers.
xp_spent is what was charged, recorded on the redemption itself. Show a customer their history from this field rather than from the reward: a published reward’s cost is fixed, but is_active is not, and the redemption stands on its own record of the charge either way.
The status is worth reading carefully. initialized is what you get here. It becomes succeeded when the burn behind it is broadcast, which is not the same as the chain having confirmed it. failed covers two endings rather than one: a broadcast that reverted, and a burn given up on before it was ever sent, which leaves the record with nothing linked to it on the chain. If you need to know the spend has landed, follow the transaction from step 5 rather than this status.
GET /gateway/redemptions is the list form of this read, filterable by customer and by reward.
7. Read the balance again
Same read as step 3, waiting for the opposite movement. The spend leaves the balance on the same delay the credit arrived on, so this loops until the number moves off what step 3 returned:200 balance=50: the 250 from step 2 less the 200 the reward cost. The customer from step 1 has been given XP and has spent some of it on a reward, and every number along the way was read back rather than assumed.
8. Spend more than the balance holds
The reward still exists and is still published, and the balance is now smaller than its cost. Redeeming it a second time produces the refusal a partner integration hits first, which is worth seeing rather than reading about:XP_INSUFFICIENT_BALANCE, and error.details names both sides of the comparison:
error.details is optional across the API, so read it when it is there and do not require it. XP_INSUFFICIENT_BALANCE is the code to branch on.
Where to go next
- Rewards and redemptions for the reward lifecycle past publication and for what each redemption status means.
- XP and the ledger for what a balance is and why the two reads above loop.
- Request conventions for the error envelope every refusal on this page arrives in, and for the full idempotency rules.
- The API reference for the rest of the gateway surface: tiers, clubs, countries, transactions and analytics.

