# 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](https://developers.loyaltylion.com/api-reference/v2/webhooks/overview):

* Respond with `200` within 5 seconds. Anything outside `2xx`, 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/customer.*` 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/loyaltylion/latest/customers.update.json):

```json
{
  "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_WEBHOOK_SECRET`, exactly as shown.

Subscribe to a topic with the v2 API:

```bash
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_approval_required` 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_id}` 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:

```bash
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](https://developers.loyaltylion.com/api-reference/v2/webhooks/overview) has examples.

### Node.js verification

```javascript
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:

```javascript
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](/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/webhooks` and 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](/webhooks/guides/implement-webhook-idempotency) shows how to record it with the update it caused.

### Respond inside 5 seconds

Queue first, process second. See [why asynchronous processing matters](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) keeps LoyaltyLion's view of your endpoint healthy while your services work at their own pace.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle LoyaltyLion webhooks reliably in minutes.