Guide to LoyaltyLion Webhooks: Features and Best Practices
LoyaltyLion runs loyalty and referral programmes for online shops: customers earn points for orders and actions, move through tiers, claim rewards and refer friends. Merchants and app developers use its webhooks to keep a CRM, email platform or storefront in step with each customer's points, tier and rewards, without polling the API.
This guide covers subscribing to LoyaltyLion topics, which secret signs a given webhook, verifying the base64 X-LoyaltyLion-Hmac-SHA256, and the 30-failure rule that deletes subscriptions.
What are LoyaltyLion webhooks?
LoyaltyLion webhooks are subscriptions created through the v2 API, one topic per subscription, either with an API key or as an OAuth client. A subscription made by an OAuth client is only visible to that client, and subscriptions made with API keys are never visible to OAuth clients.
How a subscription was created also decides how it's signed: OAuth subscriptions use the OAuth client's secret, and the rest use the site secret. One topic works differently. rewards/store_fulfilment isn't subscribed through the API at all, but configured as a fulfilment URL on a custom reward in the LoyaltyLion admin.
LoyaltyLion webhook features
| Feature | Details |
|---|---|
| Signature | base64 HMAC-SHA256 |
| Signature header | x-loyaltylion-hmac-sha256 |
| Body | JSON |
| Event routing | Body field topic |
From LoyaltyLion's webhooks overview:
- Respond with
200within 5 seconds. Anything outside2xx, or no answer in 5 seconds, is a failed delivery. - Failed events are retried with exponential backoff.
- After 5 failed attempts LoyaltyLion sends an email alert. After 30, it removes the subscription and emails again.
- Delivery is at least once, and some topics have a cool-down period.
Events and payloads
| Event routing | Body field topic |
The full topic list also includes program_ events for rule completion, approaching tier upgrades, reward availability reminders, approaching points and reward expiry, segment moves (loyal, at risk, defected) and receipt approval or decline. The Create Webhook API accepts three challenge topics that don't yet have event pages.
Every body has id, topic and created_at, plus the event's own data. created_at is when the webhook was sent, which can differ from when the event happened. This is the customers/update sample from the webhook samples library:
{
"id": "bff1bddb-6cd8-4c2c-9619-18744657053c",
"topic": "customers/update",
"created_at": "2025-01-01T03:00:05.000Z",
"customer": {
"id": 1001,
"email": "alice@example.com",
"guest": false,
"blocked": false,
"birthday": "1970-01-01",
"enrolled": true,
"metadata": {},
"created_at": "2020-11-23T17:28:25.362Z",
"properties": {
"name": "Alice Blue",
"last_name": "Blue",
"first_name": "Alice"
},
"updated_at": "2025-02-13T00:00:11.535Z",
"enrolled_at": "2023-05-12T20:46:40.377Z",
"merchant_id": "5000001",
"referral_id": null,
"referred_by": null,
"points_spent": 0,
"referral_url": "https://prz.io/a",
"points_pending": 0,
"points_approved": 500,
"rewards_claimed": 0,
"rewards_used": 0,
"insights_segment": null,
"loyalty_tier_membership": {
"manual": false,
"expires_at": null,
"started_at": "2024-12-17T02:03:15.378Z",
"loyalty_tier": {
"id": 100,
"name": "Bronze",
"hidden": false,
"number": 1,
"default": true,
"position": 0,
"lower_bound": "0.00",
"upper_bound": "249.99"
}
},
"tier_eligibility": {
"kind": "points",
"valid_until": null,
"tier_points": {
"now": 150,
"at_expiration": null
},
"points_needed_for_renewal": null,
"points_needed_for_upgrade": 100,
"upgrade_tier": {
"id": 101,
"name": "Silver",
"threshold": 250
},
"forecasted_tier": null
}
}
}
customer has the same shape as the Customers API response, so the points and tier data you need is already there. The overview page describes the event data as a payload object, while the per-topic examples put it at the top level under names such as customer; read the topic page for each event you handle.
Setting up LoyaltyLion webhooks
Pick the secret first.
Paste the OAuth client secret for webhooks registered with an OAuth client, or your site secret for other webhooks.
Store it as LOYALTYLION_, exactly as shown.
Subscribe to a topic with the v2 API:
curl -X POST https://api.loyaltylion.com/v2/webhooks \
-H "Authorization: Bearer $LOYALTYLION_API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook": {"topic": "program_events/customer.points_earned", "address": "https://example.com/webhooks/loyaltylion"}}'
address must start with https://. Some topics need a scope: customers/update and receipts/manual_ need read_customers. The older Basic auth with a site token and secret is deprecated. GET /v2/webhooks lists subscriptions and DELETE /v2/webhooks/{webhook_ removes one.
For rewards/store_fulfilment, set the fulfilment URL on the custom reward in the admin. LoyaltyLion sends a request with test: true when you save the reward, and a 2xx to a real request marks that redemption as fulfilled.
To use Hookdeck, create a LoyaltyLion source with the matching secret, and pass its URL as address. Locally:
npx hookdeck-cli listen 3000 loyaltylion --path /webhooks/loyaltylion
Securing LoyaltyLion webhooks
X-LoyaltyLion-Hmac-SHA256 is a base64 HMAC-SHA256 of the raw body, keyed with the secret's UTF-8 bytes. Compute your own base64 digest and compare the strings in constant time. Compare case-sensitively, since base64 isn't case-insensitive like hex. LoyaltyLion's overview has examples.
Node.js verification
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['x-loyaltylion-hmac-sha256'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Most failed checks come from using the wrong secret: a webhook registered by an OAuth client won't verify with the site secret, and the reverse.
The handler stores each event by id within the 5-second limit:
const express = require('express');
const app = express();
app.post('/webhooks/loyaltylion', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.LOYALTYLION_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
let body;
try {
body = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!body || typeof body !== 'object' || Array.isArray(body)) return res.sendStatus(400);
try {
await storeLoyaltyEvent(body.id, {
topic: body.topic,
sentAt: body.created_at,
customerId: body.customer?.id ?? null,
body,
});
} catch {
return res.sendStatus(503);
}
res.sendStatus(200);
});
storeLoyaltyEvent skips an id it already has. rewards/store_fulfilment has no topic envelope and goes to the reward's own URL, so give it a separate route: there, return 2xx only once the reward has actually been granted, because that response marks the redemption as fulfilled.
LoyaltyLion signs no timestamp. Hookdeck's LoyaltyLion source checks the same base64 HMAC, replies 200, and records a mismatch as failed verification.
Make LoyaltyLion webhooks production-ready. Hookdeck Event Gateway answers LoyaltyLion within its 5-second limit every time, so subscriptions are never removed for failures, and delivers points and tier events to your systems at a pace they can handle.
LoyaltyLion webhook limitations and pain points
Subscriptions are deleted after 30 failures
The Problem: After an extended outage, LoyaltyLion stops sending a topic altogether.
Why It Happens: Each failed attempt counts. At 5 you get an email, and at 30 LoyaltyLion removes the subscription.
Workarounds:
- Route the alert email somewhere that gets attention, especially for OAuth apps, where it goes to the address that registered the app.
- After an incident, list subscriptions with
GET /v2/webhooksand recreate missing ones.
How Hookdeck Can Help: Hookdeck acknowledges every delivery, so the failure count never climbs, and it retries your service on its own schedule.
A 5-second deadline
The Problem: Handlers that update a CRM or send an email before replying count as failures.
Why It Happens: LoyaltyLion treats anything slower than 5 seconds as failed.
Workarounds:
- Store the event and respond, then process it in a worker.
- Avoid cold-start delays in the request path.
How Hookdeck Can Help: Hookdeck responds immediately, and your service gets a delivery timeout you choose.
Two possible signing secrets
The Problem: Signatures fail even though the code is correct.
Why It Happens: Webhooks created by an OAuth client are signed with that client's secret, while those created with API keys use the site secret.
Workarounds:
- Record which credentials created each subscription.
- Use separate receivers or Hookdeck sources for OAuth and API key subscriptions.
How Hookdeck Can Help: A Hookdeck source per secret keeps each set of subscriptions verified correctly, and failed verifications are visible in the request log.
Payload shape differs between pages
The Problem: It's unclear whether event data sits under payload or at the top level.
Why It Happens: The overview describes a payload object, while topic examples such as customers/update place customer at the top level.
Workarounds:
- Follow the topic page for each event and capture a real delivery before writing mappings.
- Read both locations defensively until you've confirmed one.
How Hookdeck Can Help: Hookdeck stores every delivery in full, so you can see the actual shape for each topic.
Best practices
Deduplicate on id
LoyaltyLion says duplicates are rare but possible and recommends the body's id as the deduplication key. Our webhook idempotency guide shows how to record it with the update it caused.
Respond inside 5 seconds
Queue first, process second. See why asynchronous processing matters.
Treat fulfilment differently
rewards/store_fulfilment is a request to grant something, not a notification. Its status code has consequences, so don't return 2xx until the reward exists.
Expect cool-downs
Some topics have a cool-down period, described on the topic's page. Don't treat the absence of an event as proof that nothing changed; use customers/update or the API for current state.
Conclusion
LoyaltyLion webhooks keep your systems in step with each customer's points, tier and rewards, signed with a base64 HMAC-SHA256 in X-LoyaltyLion-Hmac-SHA256. Use the right secret for each subscription, verify the raw body, deduplicate on id, and answer within 5 seconds, because 30 failures remove the subscription.
Hookdeck Event Gateway keeps LoyaltyLion's view of your endpoint healthy while your services work at their own pace.
Get started with Hookdeck for free and handle LoyaltyLion webhooks reliably in minutes.