Skip to main content
This walks through the whole flow against a local dev install (localhost:4000). Adapt the host and secret for staging / production.
All snippets assume bash, curl, openssl, python3, and Node ≥ 20.

0. Sandbox credentials

Ask us for a test partner; for local development you can use the seeded acme partner whose credentials are printed at the bottom of npm run db:seed. A worked set looks like:
slug:        acme
hmac secret: dev_acme_secret_change_me
issuer:      https://acme.example.test
private key: .secrets/acme.private.pem

1. Provision a user from your backend

SECRET='dev_acme_secret_change_me'
BODY='{"externalUserId":"usr_demo_001","email":"demo@acme.example.test","displayName":"Demo User","countryCode":"SG"}'
T=$(date +%s)
V1=$(printf '%s' "${T}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)

curl -sS -X POST http://localhost:4000/integrations/users \
  -H 'content-type: application/json' \
  -H 'x-partner-slug: acme' \
  -H "x-signature: t=${T},v1=${V1}" \
  -d "$BODY"
Expected response:
{
  "userId": "8d2a7f3b-1c4e-4b9a-9f55-d3a8e2c1b7d4",
  "created": true
}
Save userId if you want to look the user up in our admin panel — it’s not required by any subsequent call.

2. Mint a JWT for the user

Save this script as mint-jwt.mjs:
mint-jwt.mjs
import { SignJWT, importPKCS8 } from 'jose';
import { readFileSync } from 'node:fs';

const pem = readFileSync('.secrets/acme.private.pem', 'utf8');
const key = await importPKCS8(pem, 'RS256');

const jwt = await new SignJWT({
  email: 'demo@acme.example.test',
  displayName: 'Demo User',
})
  .setProtectedHeader({ alg: 'RS256' })
  .setIssuer('https://acme.example.test')
  .setSubject('usr_demo_001')
  .setIssuedAt()
  .setExpirationTime('1h')
  .sign(key);

process.stdout.write(jwt);
Run it:
JWT=$(node mint-jwt.mjs)
echo "$JWT"

3. Bootstrap the consumer session

curl -sS http://localhost:4000/me \
  -H "Authorization: Bearer $JWT"
Expected:
{
  "id": "8d2a7f3b-1c4e-4b9a-9f55-d3a8e2c1b7d4",
  "email": "demo@acme.example.test",
  "displayName": "Demo User",
  "partner": {
    "id": "...",
    "name": "Acme",
    "slug": "acme",
    "displayName": "Acme Rewards",
    "logoPath": "uploads/partners/acme-logo.png"
  }
}

4. Open the webview

In your native app, load:
http://localhost:5173/consumer#token=<paste-JWT-here>
(Production: https://<your-host>/consumer#token=… — the CMS / consumer SPA ships from the same origin as the API.) The webview will call /me, render the catalog, and let the user claim and redeem vouchers.

5. Receive the redemption webhook

When the user redeems a voucher, your webhookUrl receives a POST. See Webhook payloads for the body shape and a verification snippet.

6. Anonymise the user

For a deletion / GDPR-style request:
T=$(date +%s)
# Empty body — sign empty string.
V1=$(printf '%s' "${T}." | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)

curl -sS -X DELETE \
  "http://localhost:4000/integrations/users/usr_demo_001" \
  -H 'x-partner-slug: acme' \
  -H "x-signature: t=${T},v1=${V1}" \
  -w 'HTTP %{http_code}\n'
Expected: HTTP 204. The user’s PII is cleared; their redemption history is preserved. You can revive the row at any time with another POST /integrations/users.

Next steps

JWT auth

Required claims, JWKS rotation, library examples.

HMAC auth

Algorithm, rotation, signing snippets.

Webhook delivery

Retry schedule, idempotency, receiver checklist.

Errors reference

Every error code, what it means, what to do.