Coupons
Coupons are rewards automatically issued when a customer's stamp card fills. This page explains the full lifecycle and how to redeem them via the POS API.
Coupon lifecycle
Customer earns stamps → stamp card fills → coupon issued
↓
Customer shows coupon QR
↓
POST /webhook/coupons/redeem ← you call this with the qr_token
↓
coupon.redeemed webhook firesStep 1 — Coupon issued (automatic)
When you call POST /webhook/pos and the stamp card fills, the earn response includes a coupon object:
{
"data": {
"stamps": 0,
"points": 0,
"coupon": {
"id": "e3b0c442-98fc-1c14-9afb-f4c8996fb924",
"name": "Free Coffee",
"qr_token": "cq_7f3a9b2e..."
}
}
}Show the coupon to the customer (on screen or printed on receipt). The customer's Loyalite dashboard also shows active coupons with a QR code.
Step 2 — Customer redeems (you call the API)
When the customer presents a coupon at the counter, scan the coupon QR code and call:
POST /webhook/coupons/redeem
X-Api-Key: {your_secret_key}
Content-Type: application/jsonRedeem a Coupon — POST /webhook/coupons/redeem
POST /webhook/coupons/redeem
X-Api-Key: {your_secret_key}
Content-Type: application/jsonRequest body
{
"token": "cq_7f3a9b2e...",
"verification_pin": "4821",
"manual_code": false
}| Field | Required | Description |
|---|---|---|
token | ✅ | The qr_token from the coupon (scanned from the customer's QR code or from the earn response) |
verification_pin | conditional | The PIN read from the customer's dashboard. Required when the org is on the Balanced or Strict verification preset (see Fraud Detection). Omit on the first call — the server returns 412 pin_required to start the challenge. |
manual_code | optional | true if the cashier typed the coupon token by hand instead of scanning. Strict orgs reject manual entry with manual_code_disabled (422). |
Response (200)
{
"data": {
"id": "e3b0c442-98fc-1c14-9afb-f4c8996fb924",
"name": "Free Coffee",
"status": "redeemed",
"qr_token": "cq_7f3a9b2e...",
"issued_at": "2026-04-10T10:00:00Z",
"redeemed_at": "2026-04-19T14:30:00Z"
}
}| Field | Type | Description |
|---|---|---|
id | UUID | Coupon's internal ID |
name | string | Coupon name as configured by the merchant |
status | string | "redeemed" |
qr_token | string | The token (same as input) |
issued_at | ISO 8601 | When the coupon was created |
redeemed_at | ISO 8601 | When it was just redeemed |
Error cases
| Condition | HTTP | Error code |
|---|---|---|
token missing | 400 | token is required |
| Coupon not found in your org | 404 | coupon not found |
| Coupon already used | 409 | coupon already redeemed |
| Subscription expired | 402 | subscription_required |
| PIN required (Balanced / Strict orgs) | 412 | pin_required — body includes challenge_id. Read the PIN from the customer's dashboard and retry with verification_pin set. |
| PIN incorrect | 422 | pin_invalid — body includes remaining_attempts. After 3 wrong attempts the challenge is consumed and a new one must be issued by retrying without verification_pin. |
| PIN expired | 422 | pin_expired — challenge older than 90 s. Retry without verification_pin to issue a fresh one. |
| PIN attempts exceeded | 429 | pin_attempts_exceeded |
| Manual entry blocked (Strict orgs) | 422 | manual_code_disabled |
See Fraud Detection → PIN Validation API Flow for the full retry pattern with code examples.
A coupon.redeemed outbound webhook event fires after a successful redemption — use it to update your CRM or sync loyalty data.
Where does the qr_token come from?
There are two ways to get the qr_token:
From the earn response — when POST /webhook/pos fills a stamp card, the response includes the coupon with its qr_token. You can store this and use it later.
From the customer's QR code — the customer's Loyalite profile shows each coupon as a QR code. Scan it at the counter — the raw QR content is the qr_token, pass it directly as token.
Code example
The simplest path — works on Standard orgs and as the first call on Balanced / Strict orgs (where it surfaces a 412 pin_required you then handle):
async function redeemCoupon(qrToken, { verificationPin, manualCode } = {}) {
const res = await fetch(`${BASE}/webhook/coupons/redeem`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': API_KEY,
},
body: JSON.stringify({
token: qrToken,
verification_pin: verificationPin,
manual_code: manualCode,
}),
});
if (res.status === 404) throw new Error('Coupon not found');
if (res.status === 409) throw new Error('Coupon already used');
if (res.status === 402) throw new Error('Subscription required');
// Balanced / Strict orgs: PIN challenge required.
if (res.status === 412) {
const { challenge_id } = await res.json();
throw Object.assign(new Error('pin_required'), { challengeId: challenge_id });
}
if (!res.ok) throw new Error(`Redeem failed: ${res.status}`);
const { data } = await res.json();
console.log(`Coupon "${data.name}" redeemed at ${data.redeemed_at}`);
return data;
}For the full retry-with-PIN loop see Fraud Detection → PIN Validation API Flow.
Full stamp campaign flow
// At checkout: earn 1 stamp
const earnRes = await fetch(`${BASE}/webhook/pos`, {
method: 'POST',
headers: { 'X-Api-Key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_code: customerCode,
amount: 1, // number of stamps to award
card_type: 'stamp',
external_id: orderId,
}),
});
const { data: earnData } = await earnRes.json();
if (earnData.coupon) {
// Card filled — show reward to customer
console.log(`🎉 Reward earned: ${earnData.coupon.name}`);
// Optionally store earnData.coupon.qr_token for later redemption
}
// Later — customer presents coupon:
const coupon = await redeemCoupon(scannedQrToken);
console.log(`✅ Redeemed: ${coupon.name}`);