Skip to main content
A reward is something a customer can spend XP on, created under your tenant with a title and a cost. A redemption is one customer spending that cost on it. This page follows the path from the first to the second.

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 400 REWARD_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_FOUND when the customer is not a member of your tenant.
  • 400 XP_USER_LOCKED when that customer’s XP balance is locked, and 400 USER_LOCKED when their account is not active or reinstated.
  • 404 REWARD_NOT_FOUND when the reward is not one of yours, and 400 REWARD_INACTIVE when it is yours but is unpublished or has been deactivated.
  • 403 REWARD_TIER_LOCKED when 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_BALANCE when the customer has reached the tier and still cannot afford the reward. The response names what was required and what was available.
Handle the last two separately. A reward whose tier gate sits at or above its own cost answers a short balance with the 403 and never with the 400. The cost check reads a snapshot of the indexed balance and reserves nothing, so two redeems for one customer issued before the first burn is indexed can both pass it. Serialize a customer’s redeems rather than firing them together: a redeem the API accepted against XP another redeem had already spent still returns 200, and the surplus one reaches 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 status initialized 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