Skip to main content
The XP token is a soulbound ERC20 that holds every customer’s XP on chain. A transfer reverts unless one side of it is the zero address, so a balance moves by mint and by burn and by nothing else.

Mint and burn are the two halves you already know

A mint is the on-chain half of an earn, and a burn is the on-chain half of a redemption. Each XP event is one chain transaction rather than a batched one. Both are gated. Mint takes MINTER_ROLE and burn takes BURNER_ROLE, and each has an equivalent that accepts a signature from a signer the contract has registered instead of a role. The contract carries four roles, ADMIN_ROLE, MINTER_ROLE, BURNER_ROLE and SET_SIGNER_ROLE, with ADMIN_ROLE administering all four. Both carry a hash the contract consumes once, so the same operation cannot be applied twice even if it reaches the chain twice. Both emit an event carrying the customer id alongside the address and the amount, a mint as Issue and a burn as Redeem, so a chain record maps back to a customer without a lookup on your side. The transfer that is refused fails with the contract’s own KintraXPTransferDisabled error, raised whenever neither side of a movement is the zero address. That is the guarantee stated as code: no customer can send XP to another, and no integration can move it for them.

There is one balance, and the chain holds it

This is the part to get right before you build against it. Kintra keeps no separate XP total of its own. A balance read sums the on-chain balances of the wallets Kintra manages for that customer, an earn increments no stored number, and a redemption decrements none. So a caller reading GET /gateway/customers/{userId}/xp/balance is reading the chain’s number, arrived at through an indexer that follows the token’s transfer events and calls the token back for the balances they name. What you are not reading is a ledger the API maintains alongside it. The internal transaction record is the intent and the audit trail: what was granted or spent, for which customer, and which chain transaction carried it. Its amount is an unsigned magnitude and its direction is in type, so it is a history rather than a balance, and summing it is not how you find out what a customer holds. The practical consequence is timing, and it is the same one on both pages that mention it. A balance read taken straight after an accepted earn can still answer the old number, because the credit lands when the mint is indexed rather than when the write returns. A redemption behaves the same way in reverse. A chain transaction that reverts leaves the internal transaction at failed and the balance where it was.

What the token is and is not

XP is a whole number. The token’s decimals is fixed in code at zero rather than stored as a setting, so there is no fractional unit of a point and there never will be for this implementation. One token holds every tenant’s XP. The XP asset is one record per chain, and the separation between tenants is enforced in the API rather than by a contract per tenant. The token runs behind a proxy and is upgradeable, with the upgrade gated on ADMIN_ROLE. The address you read a balance from is the proxy’s, and it stays the same when the implementation behind it changes. Because it is an ERC20 on an EVM chain, a balance can be read from the contract directly as well as through the API, which is how the platform reads it too. Responses name chain_id and the asset address as facts about where the token lives.

The chain

Kintra runs on LightLink, and it is the only chain the platform supports. Nothing you write names a chain. No request body carries a chain field, and the two list reads that accept chain_id take it as an optional filter, which narrows what comes back rather than choosing where anything is written. What the design asks of a chain is small writes, often, one per member event, because every earn is a mint and every redemption is a burn.

Where to go next