Skip to main content
The Quickstart gets a signed request accepted and stops at the balance read. This page walks the same ground more slowly, says why each request is shaped the way it is and what comes back when one is refused, then keeps going into the half the quickstart leaves out: a reward created, spent, and a balance that moves back down. Run it top to bottom in one shell. Later steps use identifiers earlier ones captured, so the order is the page rather than a suggestion. You need an API key id and its signing secret, and curl, openssl and jq. Authentication covers where the credential comes from, what every request carries, and what to check when one is rejected. A credential you have never used is better tested on the quickstart’s read than on the write below, which creates a record. 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:
Then hold your credentials and split that base URL into the host you send to and the path the signature covers. Every step reuses these four variables:

1. Create the customer

A customer is the record everything else on this page hangs off. Creating one needs an email address and nothing else, and the id it returns is the parameter every later step takes.
A 201 with an id is the answer you want. Two others are worth deciding about now rather than in production: 409 with USER_ALREADY_EXISTS. That address is already a customer of your tenant. Reading it as “this one is already in” is reasonable during an import, but the refusal hands back no id, so take the id here rather than planning to look one up by address later. 400 with IDEMPOTENCY_KEY_REQUIRED. X-Idempotency-Key is not optional on this route. Send a fresh random value per write, and a request that times out is then safe to send again under the same key: the second attempt answers with the recorded result and creates nobody. Store CUSTOMER_ID against your own user record. Nothing you send names your user id, so this is the only direction the mapping runs.

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.
A fresh idempotency key again, because this is a different request. Reusing the one from step 1 returns 422 with 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:
That prints 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.
A 201 with an id again. The create answers with the id alone, so read the reward back with 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:
A 200, and what it carries is a transaction rather than a redemption. 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

That prints 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:
That prints 200 balance=50: the 250 from step 2 less the 200 the reward cost. A customer has been created, given XP and 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:
That comes back 400 with XP_INSUFFICIENT_BALANCE, and error.details names both sides of the comparison:
Two notes on that. A fresh idempotency key is what makes this a second attempt: reusing step 5’s key would replay the stored 200 and spend nothing, which is the behaviour you want on a retry and the wrong one for a new request. And this step only refuses because step 7 waited: sent while the burn was still unindexed, it would have read the old balance and been accepted. 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.