POS Integration
Coupons

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 fires

Step 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/json

Redeem a Coupon — POST /webhook/coupons/redeem

POST /webhook/coupons/redeem
X-Api-Key: {your_secret_key}
Content-Type: application/json

Request body

{
  "token": "cq_7f3a9b2e...",
  "verification_pin": "4821",
  "manual_code": false
}
FieldRequiredDescription
tokenThe qr_token from the coupon (scanned from the customer's QR code or from the earn response)
verification_pinconditionalThe 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_codeoptionaltrue 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"
  }
}
FieldTypeDescription
idUUIDCoupon's internal ID
namestringCoupon name as configured by the merchant
statusstring"redeemed"
qr_tokenstringThe token (same as input)
issued_atISO 8601When the coupon was created
redeemed_atISO 8601When it was just redeemed

Error cases

ConditionHTTPError code
token missing400token is required
Coupon not found in your org404coupon not found
Coupon already used409coupon already redeemed
Subscription expired402subscription_required
PIN required (Balanced / Strict orgs)412pin_required — body includes challenge_id. Read the PIN from the customer's dashboard and retry with verification_pin set.
PIN incorrect422pin_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 expired422pin_expired — challenge older than 90 s. Retry without verification_pin to issue a fresh one.
PIN attempts exceeded429pin_attempts_exceeded
Manual entry blocked (Strict orgs)422manual_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}`);