Agent skill
LoyaltyLion Webhooks Skill
Receive and verify LoyaltyLion webhooks. Use when setting up a LoyaltyLion webhook endpoint, debugging x-loyaltylion-hmac-sha256 signature verification, or handling loyalty events like customers/update, program_events/customer.points_earned, program_events/customer.tier_upgraded and rewards/store_fulfilment.
Install this skill
npx skills add hookdeck/webhook-skills --skill 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-sha256signature? - 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_orevents/customer.points_ earned 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.
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);
}
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/, examples/nextjs/, examples/fastapi/.
Event Types
The body is an envelope { id, topic, created_: dispatch on topic. The 21 documented topics:
| Topic | Triggered when |
|---|---|
customers/update | A customer changes in LoyaltyLion (points earned or redeemed, new tier, ...) |
loyalty_ | 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_ | An uploaded receipt needs manual review |
program_ | A customer joins the program |
program_ | A customer earns points |
program_ | A customer claims a reward |
program_ / customer.tier_downgraded | A customer moves up or down a tier |
program_ | A referral completes |
program_ | A customer completes a rule |
program_ | A customer is close to the next tier |
program_ / customer.recurring_ | A reward is available / a reminder for a recurring reward |
program_ / customer.approaching_ | Points or a reward are about to expire |
program_ / moved_to_at_risk_segment / moved_ | A customer's insights segment changes |
program_ / customer.receipt_ | A receipt is approved or declined |
The Create Webhook API also accepts program_, customer.challenge_ and customer.challenge_; 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
LOYALTYLION_WEBHOOK_SECRET=your_loyaltylion_secret # OAuth client secret, or site secret
Local Development
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 - Payload envelope, topics, delivery and retries
- references/setup.md - Subscribing to topics with the v2 Webhook API, choosing the secret
- references/verification.md - Signature check in Node, Python and Next.js, gotchas
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests