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.