Tenant isolation
A gateway request carries no tenant field. The tenant comes from the API key id inX-Tenant-Key, and the server attaches the resolved tenant to the request before any handler runs, so a request cannot name a tenant it does not hold a key for.
Every gateway route reloads that tenant and checks that it is active. A tenant that is not active gets 403 TENANT_SUSPENDED rather than a partial answer.
A customer is addressed as the pair of your tenant and the customer id. An id that belongs to another tenant returns 404 USER_NOT_FOUND, which is the same answer as an id that exists nowhere.
Membership is one record per tenant and customer pair.
On the portal surface the tenant is named in the path, and it is checked against the calling user’s own memberships. A tenant you are not a member of returns 404 TENANT_NOT_FOUND, and a membership that lacks the permission for the operation returns 403 AUTH_INSUFFICIENT_PERMISSIONS.
What an XP balance is
XP is a whole number. The token declares no decimal places, and every amount the API returns is a string, so parse the amounts you read as integers and never as floats. The earn request goes the other way: it declaresamount as a JSON number, so send it unquoted.
A balance read answers the balance held on chain for the XP token, summed across the customer’s wallets.
That number is not a running total the API keeps. An indexer follows the token’s transfer events, calls the token back for the balance of every address in them, and stores what the token answered. The balance you read is the chain’s number.
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. It is not the balance.
Its amount is an unsigned magnitude in both directions, and type carries the direction: xp_earn is a credit and xp_redeem a debit. xp_bonus and xp_admin_adjust are declared as well and nothing writes them. A caller summing a history applies the sign from type rather than reading it off the amount.
Tier eligibility is evaluated against that same balance and the tier’s minimum, so a redemption can move a customer back below a threshold they had passed.
A redemption checks the reward’s tier gate before it looks at the cost. A reward gated to a tier whose minimum is above the customer’s balance returns 403 REWARD_TIER_LOCKED with required_tier_min_xp and available, and the cost comparison never runs.
The cost comparison is next: it returns 400 XP_INSUFFICIENT_BALANCE with required and available when the balance is short. Handle both codes, because a reward gated above its own cost answers the short-balance case with the 403.
What a caller observes while it settles
POST /gateway/xp/earn returns once the platform has recorded the grant and queued the mint. It does not wait for the chain.
The response carries the internal transaction at status initialized, and no balance.
The status becomes pending when the signed chain transaction has been broadcast, then completed or failed from the chain receipt. The four values are initialized, pending, completed and failed.
Follow it by reading the transaction back, on GET /gateway/customers/{userId}/transactions or GET /gateway/internal-transactions/{internal_transaction_id}.
The chain transaction is at blockchain_transaction_links.blockchain_transaction, a list, and each entry carries tx_hash and txn_status.
The list is empty until the transaction has been broadcast, so a read taken straight after an accepted earn shows the internal transaction at initialized with no chain transaction in it yet. It can also hold more than one entry, because more than one chain transaction can be linked to one internal transaction, so read the entries rather than assuming a single one. Both the list and the object holding it are nullable in the spec.
txn_status has seven values: initialized, submitted, pending, succeeded, failed, replaced and canceled. Terminal success is succeeded.
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. Poll the balance, or the transaction status, instead of assuming the balance moved with the write.
A redemption behaves the same way in reverse: the cost leaves the balance when the burn is indexed.
Failure surfaces in the same place. A chain transaction that reverts leaves the internal transaction at failed, and the balance never moves.
The XP token on chain
The XP token is a soulbound ERC20. 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. A mint is the on-chain half of an earn, and a burn is the on-chain half of a redemption. Mint and burn are gated onMINTER_ROLE and BURNER_ROLE, and each has an equivalent that takes an EIP-712 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, and ADMIN_ROLE administers all four.
Every mint and every burn carries a hash the contract consumes once, so the same operation cannot be applied twice even if it reaches the chain twice.
A mint emits Issue and a burn emits Redeem, each carrying the customer id alongside the address and the amount, so a chain record maps back to a customer without a lookup.
The token runs behind a proxy and is upgradeable, with the upgrade gated on ADMIN_ROLE.
One token holds every tenant’s XP. The XP asset is one record per chain, and tenant separation is enforced in the API rather than by a contract per tenant.
The chain
Kintra runs on LightLink, and it is the only chain the platform supports. Every earn is one mint and every redemption is one burn, so the platform writes one chain transaction per XP event rather than batching them. That is what the design asks of a chain: small writes, often, one per member event. Nothing you write names a chain.chain_id and the asset address appear in responses as facts about where the XP token lives, not as choices, and no request body carries a chain field. Two list reads accept chain_id as an optional filter, which narrows what comes back rather than choosing where anything is written.
Because the token 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. 
