Creating a reward
POST /gateway/rewards takes a title and an XP cost, and optionally a description, freeform metadata, and the id of a tier that gates it. The cost is a whole number of at least one point.
The gate is the min_tier_id field. It has to name a tier of your own tenant, and one that does not returns 404 TIER_NOT_FOUND; leaving it out or sending it empty means the reward is open to every customer.
A reward has two statuses, initialized and published. A create with no status stores initialized, and a create may set published straight away.
Publishing freezes it
Publishing is the point of no return, and it is worth getting the reward right before you cross it. An update to a published reward returns 400REWARD_PUBLISHED unless the only field in the body is is_active, and a delete of a published reward returns the same code.
So is_active is the one control you keep. Setting it to false takes a published reward out of circulation without removing it, which is what you use instead of a delete.
Before publication a delete is available and is a soft delete: the reward stops being visible rather than being erased, so redemptions that already reference it still read back.
Creating a reward and updating one both require X-Idempotency-Key. Deleting one does not, even though the generated reference declares the header: the middleware that enforces it exempts every DELETE.
Redeeming
POST /gateway/xp/redeem takes the customer and the reward. The cost is not in the request: it comes from the reward, which is what makes a published reward’s price the one a customer actually paid.
The refusals run in a fixed order, and knowing it saves you guessing which one a client hit:
- 404
USER_NOT_FOUNDwhen the customer is not a member of your tenant. - 400
XP_USER_LOCKEDwhen that customer’s XP balance is locked, and 400USER_LOCKEDwhen their account is notactiveorreinstated. - 404
REWARD_NOT_FOUNDwhen the reward is not one of yours, and 400REWARD_INACTIVEwhen it is yours but is unpublished or has been deactivated. - 403
REWARD_TIER_LOCKEDwhen the reward is gated to a tier the customer’s balance has not reached. The cost is never compared when this one fires. - 400
XP_INSUFFICIENT_BALANCEwhen the customer has reached the tier and still cannot afford the reward. The response names what was required and what was available.
failed after the fact.
Two further refusals can arrive in the same sequence, and both are about the environment rather than the request: 503 XP_SERVICE_NOT_AVAILABLE when the XP service is unavailable, and 404 ONCHAIN_ASSET_NOT_FOUND when no XP asset is configured for the chain. Both mean retry rather than change the request.
A redeem requires X-Idempotency-Key, and replaying a key returns the stored response rather than spending twice.
What an accepted redemption writes
Two records, in one database transaction. The first is the internal transaction an earn also produces, at statusinitialized and carrying the amount as an unsigned magnitude with xp_redeem as its type. The second is the redemption record, which carries the reward, the XP spent, and its own status.
The response is the internal transaction, and its redemption_id is how you get from one to the other.
Read redemptions back on GET /gateway/redemptions and GET /gateway/redemptions/{redemption_id}, each carrying xp_spent, a status, and a summary of the reward that was redeemed.
What the statuses mean
A redemption record declares five statuses:initialized, pending, succeeded, failed and canceled. Three are written today, initialized on acceptance and then succeeded or failed from what becomes of the burn, including the case where the burn is never attempted, so a client switching over all five has two branches nothing reaches yet.
One of those three does not mean what its name suggests. A redemption reaches succeeded when the burn is broadcast to the chain, not when the chain confirms it, and a burn that then reverts moves the record to failed. So succeeded is not a settled balance.
Follow the internal transaction when you need to know the spend has landed: it moves to pending on the same broadcast and to completed or failed from the chain receipt, and the chain transaction under it carries the hash and its own status.
A redemption can also reach failed with no chain transaction under it at all. When the burn is given up on before it is broadcast, the record sits at initialized for the whole retry budget and then flips to failed with nothing linked, so an empty list of chain transactions is not a reliable sign that work is still in flight.
The balance follows the same clock as an earn. The cost leaves the balance when the burn is indexed, which is after the redeem call has already answered you.
Where to go next
- XP and the ledger for what a balance is and what an earn does.
- Tiers for the threshold a gated reward is measured against.
- On-chain XP for what the burn behind a redemption does.
- Request conventions for the error envelope every refusal above arrives in.

