# LoyaltyLion Webhooks

LoyaltyLion (loyalty and referral programs for e-commerce stores) sends a webhook for each topic you
subscribe to through the v2 Webhook API: customer updates, points earned, tier changes, claimed
rewards and more. Every delivery is a `POST` with a JSON body and an `x-loyaltylion-hmac-sha256`
header: the base64 HMAC-SHA256 of the raw body.

## When to Use This Skill

* How do I receive LoyaltyLion webhooks?
* How do I verify the `x-loyaltylion-hmac-sha256` signature?
* Which secret signs LoyaltyLion webhooks: the OAuth client secret or the site secret?
* Why does my LoyaltyLion webhook verification fail?
* How do I handle `customers/update`, `program_events/customer.points_earned` or `rewards/store_fulfilment`?

## Which Secret to Use

Paste the OAuth client secret for webhooks registered with an OAuth client, or your site secret for other webhooks.

Use the secret as-is (its UTF-8 bytes): it is not hex- or base64-decoded.

## Verification (core)

The signature is `base64(HMAC-SHA256(secret, raw request body))`: standard padded base64, the whole
header value, no prefix. Only the raw body bytes are signed (no timestamp or id). Check the header's
format (44 characters, `[A-Za-z0-9+/]{43}=`) before comparing, then compare the strings in constant time.

```javascript
const crypto = require('crypto');
const FORMAT = /^[A-Za-z0-9+/]{43}=$/;

function verifyLoyaltyLionSignature(rawBody, signature, secret) {   // rawBody: Buffer
  if (!secret || typeof signature !== 'string' || !FORMAT.test(signature)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
  const a = Buffer.from(signature), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

```python
import base64, hashlib, hmac, re
from typing import Optional
FORMAT = re.compile(r"[A-Za-z0-9+/]{43}=")

def verify_loyaltylion_signature(raw_body: bytes, signature: Optional[str], secret: Optional[str]) -> bool:
    if not secret or not signature or not FORMAT.fullmatch(signature):
        return False
    expected = base64.b64encode(hmac.new(secret.encode(), raw_body, hashlib.sha256).digest())
    return hmac.compare_digest(signature.encode("ascii"), expected)

```

Read the body raw (`express.raw({ type: '*/*' })`, `await request.arrayBuffer()`, `await request.body()`)
and parse JSON only after the check. Return `401` for a missing, malformed or wrong signature.
Don't decode the base64 to compare: a value with stray low bits in its last character decodes to the
same bytes but is a different string, and Hookdeck rejects it.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/loyaltylion-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/loyaltylion-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/loyaltylion-webhooks/examples/fastapi/).

## Event Types

The body is an envelope `{ id, topic, created_at, payload }`: dispatch on `topic`. The 21 documented topics:

| Topic | Triggered when |
| --- | --- |
| `customers/update` | A customer changes in LoyaltyLion (points earned or redeemed, new tier, ...) |
| `loyalty_emails/unsubscribe` | A customer unsubscribes from loyalty emails |
| `rewards/store_fulfilment` | A customer claims a custom reward with a fulfilment URL (no envelope, no `topic`) |
| `receipts/manual_approval_required` | An uploaded receipt needs manual review |
| `program_events/customer.enrolled` | A customer joins the program |
| `program_events/customer.points_earned` | A customer earns points |
| `program_events/customer.claimed_reward` | A customer claims a reward |
| `program_events/customer.tier_upgraded` / `customer.tier_downgraded` | A customer moves up or down a tier |
| `program_events/customer.referral_complete` | A referral completes |
| `program_events/customer.rule_completed` | A customer completes a rule |
| `program_events/customer.approaching_tier_upgrade` | A customer is close to the next tier |
| `program_events/customer.reward_available_notification` / `customer.recurring_reward_available_reminder` | A reward is available / a reminder for a recurring reward |
| `program_events/customer.approaching_points_expiration` / `customer.approaching_reward_expiration` | Points or a reward are about to expire |
| `program_events/customer.moved_to_loyal_segment` / `moved_to_at_risk_segment` / `moved_to_defected_segment` | A customer's insights segment changes |
| `program_events/customer.receipt_approved` / `customer.receipt_declined` | A receipt is approved or declined |

The Create Webhook API also accepts `program_events/customer.challenge_milestone_reached`,
`customer.challenge_completed` and `customer.challenge_milestone_approaching`; they have no event page in
the docs yet, so treat their payloads as unconfirmed.

`rewards/store_fulfilment` is posted to the custom reward's own fulfilment URL, so it has no
envelope and no `topic`; a `2xx` response marks the redemption as fulfilled. The `customers/update`
example in the docs puts `customer` at the top level, other topics use `payload`; the examples accept both.

## Delivery

Respond `200` within 5 seconds. A non-2xx response or timeout is retried with exponential backoff;
you get an email alert after 5 failed attempts and the subscription is removed after 30. Delivery
is at-least-once: dedupe on the body's `id`.

## Environment Variables

```bash
LOYALTYLION_WEBHOOK_SECRET=your_loyaltylion_secret   # OAuth client secret, or site secret

```

## Local Development

```bash
npx hookdeck-cli listen 3000 loyaltylion --path /webhooks/loyaltylion

```

No account required. Use the printed URL as the `url` when you subscribe with the Webhook API.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/loyaltylion-webhooks/references/overview.md) - Payload envelope, topics, delivery and retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/loyaltylion-webhooks/references/setup.md) - Subscribing to topics with the v2 Webhook API, choosing the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/loyaltylion-webhooks/references/verification.md) - Signature check in Node, Python and Next.js, gotchas
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/loyaltylion-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests