Skip to main content

Automate your Loyalty programme via the REST API

Tutorial

What you'll wire

A back-office that mints loyalty points to a customer wallet right after a checkout fires on your side, and a sync job that keeps your reward tiers aligned with your CMS or product catalogue. By the end you'll have an end-to-end automation: order paid → points credited → confirmed on-chain.

  • Time~25 min
  • DifficultyIntermediate — REST API and a back-office to call from
  • You'll needA Loyalty application (created in the Admin Console) and an API key with loyalty: ['create', 'read', 'update']

Admin first — what doesn't belong in your code

The Admin Console covers everything an operator needs to do by hand: create the application, configure settings, draft reward tiers, mint a one-off bonus, browse orders. Don't reproduce that in your code. Reach for the REST API only for the cases where a human-in-the-loop is not an option.

Stay in the Admin ConsoleMove to your back-office
Creating the Loyalty application and its settingsMint after a checkout webhook fires on your side
Drafting and reviewing reward tiersSync tiers from a CMS / config file when offers evolve
Ad-hoc reward to a specific customerBulk awards from a campaign engine
Browsing orders and audit logsConfirm on-chain settlement before fulfilment

This page covers the right column.

How a single mint cycle looks end-to-end

  1. Storefront / POSemits checkout event
  2. Your back-officeorchestrates the mint
  3. Keyban APIloyalty + indexer
  4. Ledgersettles on-chain
  1. 1checkout.completed
  2. 2POST /v1/loyalty/account/:id/mint
  3. 3queue mint tx
  4. 4settled (block N)
  5. 5GET /v1/loyalty/orders?walletId=…
  6. 6settled order
Your storefront posts a paid order to your back-office. Your back-office calls Keyban's mint endpoint. Keyban queues the on-chain transfer; the indexer materialises the resulting order and your back-office polls /v1/loyalty/orders to confirm settlement before notifying the customer.

Step 1 — Set the environment

export KEYBAN_API="https://api.prod.keyban.io"
export KEYBAN_API_KEY="<your-key-secret>"

The KEYBAN_API_KEY is created in Admin → Organization → API keys with the permissions listed in You'll need. Treat the secret as you would any production credential — server-side only, rotated on suspected leak.

Step 2 — Mint points after a checkout

When your storefront fires its checkout.completed event, your back-office calls the mint endpoint. The account is identified by its Keyban id (UUID), not the wallet address — fetch it once at customer sign-up and persist it alongside your customer record.

// inside your checkout webhook handler
async function awardPoints(accountId: string, amount: number) {
const res = await fetch(
`${process.env.KEYBAN_API}/v1/loyalty/account/${accountId}/mint`,
{
method: "POST",
headers: {
"X-Api-Key": process.env.KEYBAN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ amount }),
}
);

if (!res.ok) {
// RFC 7807 Problem+JSON — branch on status, not on the title
const problem = await res.json();
throw new Error(`Mint failed: ${res.status} ${problem.detail ?? ""}`);
}

const { newamount } = await res.json();
return newamount; // optimistic balance after the mint
}

The response carries an optimistic balance — it reflects the mint your service issued, not the on-chain truth. Display it to the customer as a fast feedback signal, but don't unlock anything irreversible until Step 4 confirms settlement.

For error handling patterns (retries on 429, idempotency keys, fallback behaviour) see How to handle API errors.

Step 3 — Sync reward tiers from your CMS

When your marketing or product team updates an offer in their tool of choice, you want the change reflected in Keyban without anyone touching the Admin Console. Treat tiers as configuration: the source of truth lives in your CMS or a versioned YAML/JSON in your repo, and a CI step reconciles it with Keyban.

type TierSpec = {
externalId: string; // your CMS's stable id
name: string;
requiredVisits: number;
currencyToPointRatio: number;
};

async function syncTier(spec: TierSpec, applicationId: string) {
const existing = await listTiers(applicationId);
const match = existing.find((t) => t.name === spec.name);

if (!match) {
// Create new tier
return fetch(`${process.env.KEYBAN_API}/v1/loyalty/reward-tiers`, {
method: "POST",
headers: {
"X-Api-Key": process.env.KEYBAN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
application: applicationId,
name: spec.name,
requiredVisits: spec.requiredVisits,
currencyToPointRatio: spec.currencyToPointRatio,
}),
});
}

// Update only when something actually changed
if (
match.requiredVisits === spec.requiredVisits &&
match.currencyToPointRatio === spec.currencyToPointRatio
) {
return; // no-op
}

return fetch(
`${process.env.KEYBAN_API}/v1/loyalty/reward-tiers/${match.id}`,
{
method: "PATCH",
headers: {
"X-Api-Key": process.env.KEYBAN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
requiredVisits: spec.requiredVisits,
currencyToPointRatio: spec.currencyToPointRatio,
}),
}
);
}

Two design points worth internalising:

  • The sync is upsert by name, not by id. Keyban issues the id on creation; your CMS only knows its own externalId. Match on name (or store the Keyban id in your CMS once the tier is created — both are valid).
  • Don't DELETE a tier from a sync job unless you accept that customers who already qualified for it lose their entitlement. Prefer an « archived » flag in your CMS that simply stops new qualifications.

Step 4 — Confirm on-chain settlement

For automations that gate downstream steps (a goodie that ships only after the points are real on-chain), poll the orders endpoint until the settled block number appears.

async function waitForSettlement(walletId: string, expectedAmount: number) {
for (let attempt = 0; attempt < 6; attempt++) {
const res = await fetch(
`${process.env.KEYBAN_API}/v1/loyalty/orders?` +
new URLSearchParams({
walletId,
currentPage: "1",
pageSize: "10",
}),
{ headers: { "X-Api-Key": process.env.KEYBAN_API_KEY! } }
);
const body = await res.json();

const settled = body.data.find(
(o: any) =>
o.orderTransactions?.nodes?.[0]?.assetTransfer?.value ===
String(expectedAmount) &&
o.orderTransactions?.nodes?.[0]?.assetTransfer?.transaction
?.blockNumber != null
);

if (settled) return settled;
await new Promise((r) => setTimeout(r, 5_000));
}

throw new Error("Mint did not settle within the timeout window");
}

Settlement typically takes a handful of seconds on Stellar. Tune the backoff window to match the network used by your application — Plan your network environment covers the practical implications.

What stays outside the API surface

A few capabilities deliberately do not have a partner-facing REST endpoint:

  • Buyer-side redemptions. A redemption is signed by the customer's embedded wallet through @keyban/sdk-react (or via a pre-wired POS integration like the Zelty integration). You can't burn points server-side on a customer's behalf — that would break the non-custodial contract.
  • Tier deletion in production. The endpoint exists, but a deleted tier severs every customer entitlement that referenced it. Use the Admin Console for the rare cases where this is genuinely intended.
  • Account purge. Loyalty accounts mirror the on-chain wallet — the history is immutable by design.

Next steps