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:
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.