POS Integration
Examples

Examples

Points campaign: enroll → earn → redeem

End-to-end example: a customer provides their email at checkout, earns points on their purchase, and redeems points on a future visit.

Step 1 — Enroll or look up the customer

const BASE = 'https://kahveci.loyalite.app';
const API_KEY = process.env.LOYALITE_API_KEY;
 
async function enrollCustomer(email) {
  const res = await fetch(`${BASE}/webhook/customer`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': API_KEY,
    },
    body: JSON.stringify({
      email,
      consent_terms: true,
      consent_privacy: true,
    }),
  });
  if (!res.ok) throw new Error(`Enroll failed: ${res.status}`);
  const { data } = await res.json();
  return data; // { customer_code, is_new, masked_email, stamp_count, point_balance }
}

Step 2 — Earn points after purchase

The simple version below works on Standard and Balanced orgs. Strict orgs additionally require a PIN — see Earn with PIN verification below.

async function earnPoints(customerCode, amount, orderId) {
  const res = await fetch(`${BASE}/webhook/pos`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': API_KEY,
    },
    body: JSON.stringify({
      customer_code: customerCode,
      amount,
      card_type: 'point',
      external_id: orderId,
    }),
  });
  if (!res.ok) throw new Error(`Earn failed: ${res.status}`);
  const { data } = await res.json();
  return data; // { stamps, points }
}

Step 3 — Redeem points at checkout

The simple version below works on Standard verification orgs. Balanced / Strict orgs additionally require a PIN — see Redeem with PIN verification below.

async function redeemPoints(customerCode, points, redeemId) {
  const res = await fetch(`${BASE}/webhook/redeem`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': API_KEY,
    },
    body: JSON.stringify({
      customer_code: customerCode,
      value: points,
      card_type: 'point',
      external_id: redeemId,
    }),
  });
  if (res.status === 422) throw new Error('Insufficient balance');
  if (res.status === 402) throw new Error('Subscription required');
  if (!res.ok) throw new Error(`Redeem failed: ${res.status}`);
  const { data } = await res.json();
  return data; // { stamps, points } — updated balance
}

Earn with PIN verification (Strict orgs)

/webhook/pos is gated only at the Strict preset — Standard and Balanced never gate earn. The retry pattern is identical to the other gated endpoints, just with different request fields. See Fraud Detection → PIN Validation API Flow for the full state machine.

These examples assume you have a promptForPin({ remainingAttempts, challengeId }) function — typically a synchronous prompt on the cashier's screen. On non-Strict orgs the PIN loop never runs (the first call returns 200), so you can use this helper unconditionally.

async function earnPointsWithPin(customerCode, amount, orderId, promptForPin, { manualCode = false } = {}) {
  const base = {
    customer_code: customerCode,
    amount,
    card_type: 'point',
    external_id: orderId,
    manual_code: manualCode,
  };
 
  let res = await callEarn(base);
 
  while ([412, 422, 429].includes(res.status)) {
    const errBody = await res.json();
    const code = errBody.error;
 
    if (code === 'manual_code_disabled') {
      throw new Error('Strict org — scan the QR instead of typing the customer code.');
    }
 
    if (code === 'pin_expired' || code === 'pin_attempts_exceeded') {
      // Previous challenge dead — retry without a PIN to issue a fresh one.
      res = await callEarn(base);
      continue;
    }
 
    if (code === 'pin_required' || code === 'pin_invalid') {
      const pin = await promptForPin({
        remainingAttempts: errBody.remaining_attempts,
        challengeId: errBody.challenge_id,
      });
      res = await callEarn({ ...base, verification_pin: pin });
      continue;
    }
 
    // Some other 4xx — surface to caller.
    break;
  }
 
  if (!res.ok) throw new Error(`Earn failed: ${res.status}`);
  return (await res.json()).data; // { stamps, points, coupon? }
}
 
async function callEarn(payload) {
  return fetch(`${BASE}/webhook/pos`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Api-Key': API_KEY },
    body: JSON.stringify(payload),
  });
}

Redeem with PIN verification (Balanced / Strict orgs)

/webhook/redeem is gated on Balanced and Strict orgs — the same retry-with-pin loop that applies to coupon redemption. The difference: redeem deducts a value from a customer's balance instead of consuming a coupon token. The full state machine is documented in Fraud Detection → PIN Validation API Flow.

These examples assume you have a promptForPin({ remainingAttempts, challengeId }) function — typically a synchronous prompt on the cashier's screen. Replace it with whatever your POS UI uses to collect the PIN.

async function redeemPointsWithPin(customerCode, points, redeemId, promptForPin, { manualCode = false } = {}) {
  const base = {
    customer_code: customerCode,
    value: points,
    card_type: 'point',
    external_id: redeemId,
    manual_code: manualCode,
  };
 
  let res = await callRedeem(base);
 
  while ([412, 422, 429].includes(res.status)) {
    const errBody = await res.json();
    const code = errBody.error;
 
    // insufficient_balance is a normal 422 — break out and let the caller handle it.
    if (code === 'insufficient_balance') break;
 
    if (code === 'manual_code_disabled') {
      throw new Error('Strict org — scan the QR instead of typing the customer code.');
    }
 
    if (code === 'pin_expired' || code === 'pin_attempts_exceeded') {
      // Previous challenge dead — retry without a PIN to issue a fresh one.
      res = await callRedeem(base);
      continue;
    }
 
    // pin_required (412) or pin_invalid (422): collect PIN and retry.
    const pin = await promptForPin({
      remainingAttempts: errBody.remaining_attempts,
      challengeId: errBody.challenge_id,
    });
    res = await callRedeem({ ...base, verification_pin: pin });
  }
 
  if (res.status === 422) {
    const { error } = await res.json();
    if (error === 'insufficient_balance') throw new Error('Insufficient balance');
  }
  if (res.status === 402) throw new Error('Subscription required');
  if (!res.ok) throw new Error(`Redeem failed: ${res.status}`);
  return (await res.json()); // { stamps, points } — updated balance
}
 
async function callRedeem(payload) {
  return fetch(`${BASE}/webhook/redeem`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Api-Key': API_KEY },
    body: JSON.stringify(payload),
  });
}

Stamp campaign: earn stamps + handle coupon

For stamp campaigns, pass the number of stamps as amount. When the card fills, the response includes the issued coupon.

async function earnStamps(customerCode, stampsToAdd, orderId) {
  const res = await fetch(`${BASE}/webhook/pos`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': API_KEY,
    },
    body: JSON.stringify({
      customer_code: customerCode,
      amount: stampsToAdd,   // number of stamps to award
      card_type: 'stamp',
      external_id: orderId,
    }),
  });
  if (!res.ok) throw new Error(`Earn failed: ${res.status}`);
  const { data } = await res.json();
 
  if (data.coupon) {
    // Stamp card filled — a reward coupon was issued.
    console.log(`Reward earned: ${data.coupon.name}`);
    console.log(`Coupon QR: ${data.coupon.qr_token}`);
    // Show the coupon details to the customer / print on receipt.
  }
 
  console.log(`Stamps remaining: ${data.stamps}`);
  return data;
}

Response when card fills:

{
  "data": {
    "stamps": 0,
    "points": 0,
    "coupon": {
      "id": "e3b0c442-...",
      "name": "Free Coffee",
      "qr_token": "abc123..."
    }
  }
}

Response when card does not fill:

{
  "data": {
    "stamps": 7,
    "points": 0
  }
}

Coupon redemption (Standard orgs)

Once a coupon has been issued, the customer presents its QR at checkout. On Standard verification orgs the redeem is a single call:

async function redeemCoupon(qrToken) {
  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 }),
  });
  if (res.status === 404) throw new Error('Coupon not found');
  if (res.status === 409) throw new Error('Coupon already used');
  if (!res.ok) throw new Error(`Redeem failed: ${res.status}`);
  const { data } = await res.json();
  return data; // { id, name, status: 'redeemed', redeemed_at, ... }
}

Coupon redemption with PIN verification (Balanced / Strict orgs)

When the org runs the Balanced or Strict verification preset, /webhook/coupons/redeem interrupts with 412 pin_required. The cashier must read the PIN off the customer's dashboard and retry. The full state machine — including expired and exhausted PINs — is documented in Fraud Detection → PIN Validation API Flow; the snippets below are end-to-end implementations of that loop.

These examples assume you have a promptForPin({ remainingAttempts, challengeId }) function — typically a synchronous prompt on the cashier's screen. Replace it with whatever your POS UI uses to collect the PIN.

async function redeemCouponWithPin(qrToken, promptForPin, { manualCode = false } = {}) {
  // 1st call has no PIN. On Balanced / Strict orgs the server responds 412
  // and pushes a fresh PIN to the customer's dashboard at the same time.
  let body = { token: qrToken, manual_code: manualCode };
  let res = await callRedeem(body);
 
  while ([412, 422, 429].includes(res.status)) {
    const errBody = await res.json();
    const code = errBody.error;
 
    if (code === 'manual_code_disabled') {
      throw new Error('Strict org — scan the QR instead of typing the token.');
    }
 
    // pin_expired (TTL elapsed) and pin_attempts_exceeded (3 wrong tries)
    // both invalidate the previous challenge. Retry without a PIN to issue
    // a fresh one — the new code is pushed to the customer's dashboard.
    if (code === 'pin_expired' || code === 'pin_attempts_exceeded') {
      res = await callRedeem({ token: qrToken, manual_code: manualCode });
      continue;
    }
 
    // pin_required (412) on the very first call, or pin_invalid (422) on
    // a wrong entry. Either way: ask the cashier for the PIN and retry.
    const pin = await promptForPin({
      remainingAttempts: errBody.remaining_attempts,
      challengeId: errBody.challenge_id,
    });
    res = await callRedeem({ token: qrToken, manual_code: manualCode, verification_pin: pin });
  }
 
  if (res.status === 404) throw new Error('Coupon not found');
  if (res.status === 409) throw new Error('Coupon already used');
  if (!res.ok) throw new Error(`Redeem failed: ${res.status}`);
  return (await res.json()).data;
}
 
async function callRedeem(payload) {
  return fetch(`${BASE}/webhook/coupons/redeem`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Api-Key': API_KEY },
    body: JSON.stringify(payload),
  });
}
⚠️

PINs are exchanged as strings — leading zeros ("04", "0007") are significant. Don't parse the user input as an integer or you'll silently drop them and trigger pin_invalid.


QR scan flow

When a customer shows their QR code, pass the raw QR content directly to the lookup endpoint:

async function lookupByQR(qrPayload) {
  const res = await fetch(`${BASE}/webhook/customer?qr=${encodeURIComponent(qrPayload)}`, {
    headers: { 'X-Api-Key': API_KEY },
  });
  if (res.status === 422) throw new Error('Invalid or expired QR code');
  if (res.status === 404) throw new Error('Customer not found');
  if (!res.ok) throw new Error(`Lookup failed: ${res.status}`);
  const { data } = await res.json();
  return data; // { customer_code, masked_email, stamp_count, point_balance }
}
 
// Then earn stamps/points using the returned customer_code:
async function processQRScan(qrPayload, orderId) {
  const customer = await lookupByQR(qrPayload);
  const balance = await earnStamps(customer.customer_code, 1, orderId);
  console.log(`Stamp earned. New balance: ${balance.stamps} stamps`);
}

QR codes expire after 24 hours. If a customer's QR is expired, the endpoint returns HTTP 422. Ask the customer to open their Loyalite profile to refresh the QR code.


Full checkout flow (Node.js)

async function loyaliteCheckout({ email, orderTotal, orderId, pointsToRedeem = 0, promptForPin }) {
  // 1. Enroll or look up the customer.
  const customer = await enrollCustomer(email);
  console.log(`Customer ${customer.is_new ? 'enrolled' : 'found'}: #${customer.customer_code}`);
 
  // 2. Optionally redeem points first (reduces the amount charged).
  //    redeemPointsWithPin gates on Balanced and Strict, no-ops the PIN
  //    loop on Standard — single code path for every preset.
  if (pointsToRedeem > 0 && customer.point_balance >= pointsToRedeem) {
    await redeemPointsWithPin(
      customer.customer_code,
      pointsToRedeem,
      `redeem_${orderId}`,
      promptForPin,
    );
    console.log(`Redeemed ${pointsToRedeem} points`);
  }
 
  // 3. Earn points on the full purchase amount (before discount).
  //    earnPointsWithPin gates only on Strict; Standard / Balanced run
  //    through it without ever prompting for a PIN.
  const balance = await earnPointsWithPin(
    customer.customer_code,
    orderTotal,
    orderId,
    promptForPin,
  );
  console.log(`New balance: ${balance.points} points`);
 
  return { customer, balance };
}

Earn points on the original purchase amount (before any point redemption discount). This is the standard approach — customers earn on the total, not the net amount paid.

💡

Both helpers (earnPointsWithPin, redeemPointsWithPin) skip the PIN prompt entirely when the org's preset doesn't gate that action — you can use them unconditionally and your POS code stays identical across Standard, Balanced, and Strict orgs.