> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kintra.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Onboard a customer

> The onboarding path end to end: a customer, a balance, a reward, and a redemption that spends it.

The [Quickstart](/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](/concepts/rewards-and-redemptions) 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](/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:

```bash theme={null}
export KINTRA_API="https://api.kintra.io/api/v1"
```

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:

```bash theme={null}
KEY="YOUR_TENANT_KEY_ID"
SECRET="YOUR_SIGNING_SECRET"

API_PATH="/${KINTRA_API#*://*/}"
API_HOST="${KINTRA_API%"$API_PATH"}"
```

## 1. Create the customer

A [customer](/concepts/customers) 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.

```bash theme={null}
METHOD="POST"
PATH_AND_QUERY="$API_PATH/gateway/customers"

# One compact line, signed and sent unchanged. The server signs a
# re-serialization of the body it parsed, so reformatting this fails with a 401.
BODY="{\"email\":\"YOUR_CUSTOMER_EMAIL\"}"

IDEMPOTENCY_KEY="$(openssl rand -hex 16)"
TIMESTAMP="$(date +%s)"
SIGNATURE="$(printf '%s' "$TIMESTAMP$METHOD$PATH_AND_QUERY$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

RESPONSE="$(curl -sS -w '\n%{http_code}' -X "$METHOD" "$API_HOST$PATH_AND_QUERY" \
  -H "X-Tenant-Key: $KEY" \
  -H "X-Tenant-Timestamp: $TIMESTAMP" \
  -H "X-Tenant-Signature: $SIGNATURE" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: $IDEMPOTENCY_KEY" \
  --data-binary "$BODY")"

STATUS="$(printf '%s\n' "$RESPONSE" | tail -n 1)"
CUSTOMER_ID="$(printf '%s\n' "$RESPONSE" | sed '$d' | jq -r '.data.id')"
printf '%s customer=%s\n' "$STATUS" "$CUSTOMER_ID"
```

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](/concepts/tenants). 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](/concepts/xp-and-the-ledger) and has to be at least one. `notes` is optional and is kept against the entry.

```bash theme={null}
METHOD="POST"
PATH_AND_QUERY="$API_PATH/gateway/xp/earn"
BODY="{\"user_id\":\"$CUSTOMER_ID\",\"amount\":250}"

IDEMPOTENCY_KEY="$(openssl rand -hex 16)"
TIMESTAMP="$(date +%s)"
SIGNATURE="$(printf '%s' "$TIMESTAMP$METHOD$PATH_AND_QUERY$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

RESPONSE="$(curl -sS -w '\n%{http_code}' -X "$METHOD" "$API_HOST$PATH_AND_QUERY" \
  -H "X-Tenant-Key: $KEY" \
  -H "X-Tenant-Timestamp: $TIMESTAMP" \
  -H "X-Tenant-Signature: $SIGNATURE" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: $IDEMPOTENCY_KEY" \
  --data-binary "$BODY")"

STATUS="$(printf '%s\n' "$RESPONSE" | tail -n 1)"
printf '%s earned=%s\n' "$STATUS" "$(printf '%s\n' "$RESPONSE" | sed '$d' | jq -r '.data.transaction.amount')"
```

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](/concepts/xp-and-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](/concepts/onchain-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:

```bash theme={null}
METHOD="GET"
PATH_AND_QUERY="$API_PATH/gateway/customers/$CUSTOMER_ID/xp/balance"
BODY=''

BALANCE=""
STATUS=""
ATTEMPT=0

# Keep reading while the answer is a settled 0, and stop on anything that is
# not a 200: a rejected request has no balance to report.
while [ "$ATTEMPT" -lt 30 ]; do
  if [ "$ATTEMPT" -gt 0 ]; then sleep 2; fi
  ATTEMPT=$((ATTEMPT + 1))

  TIMESTAMP="$(date +%s)"
  SIGNATURE="$(printf '%s' "$TIMESTAMP$METHOD$PATH_AND_QUERY$BODY" \
    | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

  RESPONSE="$(curl -sS -w '\n%{http_code}' -X "$METHOD" "$API_HOST$PATH_AND_QUERY" \
    -H "X-Tenant-Key: $KEY" \
    -H "X-Tenant-Timestamp: $TIMESTAMP" \
    -H "X-Tenant-Signature: $SIGNATURE")"

  STATUS="$(printf '%s\n' "$RESPONSE" | tail -n 1)"
  if [ "$STATUS" != "200" ]; then break; fi

  BALANCE="$(printf '%s\n' "$RESPONSE" | sed '$d' | jq -r '.data.balance')"
  if [ "$BALANCE" != "0" ]; then break; fi
done

GRANTED="$BALANCE"
printf '%s balance=%s\n' "$STATUS" "$BALANCE"
```

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](/concepts/rewards-and-redemptions) 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](/concepts/tiers) 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.

```bash theme={null}
METHOD="POST"
PATH_AND_QUERY="$API_PATH/gateway/rewards"
BODY="{\"title\":\"Free coffee\",\"xp_cost\":200,\"status\":\"published\"}"

IDEMPOTENCY_KEY="$(openssl rand -hex 16)"
TIMESTAMP="$(date +%s)"
SIGNATURE="$(printf '%s' "$TIMESTAMP$METHOD$PATH_AND_QUERY$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

RESPONSE="$(curl -sS -w '\n%{http_code}' -X "$METHOD" "$API_HOST$PATH_AND_QUERY" \
  -H "X-Tenant-Key: $KEY" \
  -H "X-Tenant-Timestamp: $TIMESTAMP" \
  -H "X-Tenant-Signature: $SIGNATURE" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: $IDEMPOTENCY_KEY" \
  --data-binary "$BODY")"

STATUS="$(printf '%s\n' "$RESPONSE" | tail -n 1)"
REWARD_ID="$(printf '%s\n' "$RESPONSE" | sed '$d' | jq -r '.data.id')"
printf '%s reward=%s\n' "$STATUS" "$REWARD_ID"
```

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:

```bash theme={null}
METHOD="POST"
PATH_AND_QUERY="$API_PATH/gateway/xp/redeem"
BODY="{\"user_id\":\"$CUSTOMER_ID\",\"reward_id\":\"$REWARD_ID\"}"

IDEMPOTENCY_KEY="$(openssl rand -hex 16)"
TIMESTAMP="$(date +%s)"
SIGNATURE="$(printf '%s' "$TIMESTAMP$METHOD$PATH_AND_QUERY$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

RESPONSE="$(curl -sS -w '\n%{http_code}' -X "$METHOD" "$API_HOST$PATH_AND_QUERY" \
  -H "X-Tenant-Key: $KEY" \
  -H "X-Tenant-Timestamp: $TIMESTAMP" \
  -H "X-Tenant-Signature: $SIGNATURE" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: $IDEMPOTENCY_KEY" \
  --data-binary "$BODY")"

STATUS="$(printf '%s\n' "$RESPONSE" | tail -n 1)"
RESPONSE_BODY="$(printf '%s\n' "$RESPONSE" | sed '$d')"
REDEMPTION_ID="$(printf '%s' "$RESPONSE_BODY" | jq -r '.data.transaction.redemption_id')"
printf '%s transaction=%s redemption=%s\n' \
  "$STATUS" "$(printf '%s' "$RESPONSE_BODY" | jq -r '.data.transaction.id')" "$REDEMPTION_ID"
```

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

```bash theme={null}
METHOD="GET"
PATH_AND_QUERY="$API_PATH/gateway/redemptions/$REDEMPTION_ID"
BODY=''

TIMESTAMP="$(date +%s)"
SIGNATURE="$(printf '%s' "$TIMESTAMP$METHOD$PATH_AND_QUERY$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

RESPONSE="$(curl -sS -w '\n%{http_code}' -X "$METHOD" "$API_HOST$PATH_AND_QUERY" \
  -H "X-Tenant-Key: $KEY" \
  -H "X-Tenant-Timestamp: $TIMESTAMP" \
  -H "X-Tenant-Signature: $SIGNATURE")"

STATUS="$(printf '%s\n' "$RESPONSE" | tail -n 1)"
printf '%s %s\n' "$STATUS" \
  "$(printf '%s\n' "$RESPONSE" | sed '$d' \
    | jq -r '"redemption_status=\(.data.status) xp_spent=\(.data.xp_spent) reward=\(.data.reward.title)"')"
```

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:

```bash theme={null}
METHOD="GET"
PATH_AND_QUERY="$API_PATH/gateway/customers/$CUSTOMER_ID/xp/balance"
BODY=''

BALANCE=""
STATUS=""
ATTEMPT=0

while [ "$ATTEMPT" -lt 30 ]; do
  if [ "$ATTEMPT" -gt 0 ]; then sleep 2; fi
  ATTEMPT=$((ATTEMPT + 1))

  TIMESTAMP="$(date +%s)"
  SIGNATURE="$(printf '%s' "$TIMESTAMP$METHOD$PATH_AND_QUERY$BODY" \
    | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

  RESPONSE="$(curl -sS -w '\n%{http_code}' -X "$METHOD" "$API_HOST$PATH_AND_QUERY" \
    -H "X-Tenant-Key: $KEY" \
    -H "X-Tenant-Timestamp: $TIMESTAMP" \
    -H "X-Tenant-Signature: $SIGNATURE")"

  STATUS="$(printf '%s\n' "$RESPONSE" | tail -n 1)"
  if [ "$STATUS" != "200" ]; then break; fi

  BALANCE="$(printf '%s\n' "$RESPONSE" | sed '$d' | jq -r '.data.balance')"
  if [ "$BALANCE" != "$GRANTED" ]; then break; fi
done

printf '%s balance=%s\n' "$STATUS" "$BALANCE"
```

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:

```bash theme={null}
METHOD="POST"
PATH_AND_QUERY="$API_PATH/gateway/xp/redeem"
BODY="{\"user_id\":\"$CUSTOMER_ID\",\"reward_id\":\"$REWARD_ID\"}"

IDEMPOTENCY_KEY="$(openssl rand -hex 16)"
TIMESTAMP="$(date +%s)"
SIGNATURE="$(printf '%s' "$TIMESTAMP$METHOD$PATH_AND_QUERY$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

RESPONSE="$(curl -sS -w '\n%{http_code}' -X "$METHOD" "$API_HOST$PATH_AND_QUERY" \
  -H "X-Tenant-Key: $KEY" \
  -H "X-Tenant-Timestamp: $TIMESTAMP" \
  -H "X-Tenant-Signature: $SIGNATURE" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: $IDEMPOTENCY_KEY" \
  --data-binary "$BODY")"

STATUS="$(printf '%s\n' "$RESPONSE" | tail -n 1)"
printf '%s %s\n' "$STATUS" "$(printf '%s\n' "$RESPONSE" | sed '$d' | jq -c '.error')"
```

That comes back 400 with `XP_INSUFFICIENT_BALANCE`, and `error.details` names both sides of the comparison:

```json theme={null}
{
  "code": "XP_INSUFFICIENT_BALANCE",
  "message": "User does not have enough XP to redeem this reward",
  "details": { "required": "200", "available": "50" }
}
```

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](/concepts/rewards-and-redemptions) for the reward lifecycle past publication and for what each redemption status means.
* [XP and the ledger](/concepts/xp-and-the-ledger) for what a balance is and why the two reads above loop.
* [Request conventions](/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.
