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-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.

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_at, payload }: dispatch on topic. The 21 documented topics:

TopicTriggered when
customers/updateA customer changes in LoyaltyLion (points earned or redeemed, new tier, ...)
loyalty_emails/unsubscribeA customer unsubscribes from loyalty emails
rewards/store_fulfilmentA customer claims a custom reward with a fulfilment URL (no envelope, no topic)
receipts/manual_approval_requiredAn uploaded receipt needs manual review
program_events/customer.enrolledA customer joins the program
program_events/customer.points_earnedA customer earns points
program_events/customer.claimed_rewardA customer claims a reward
program_events/customer.tier_upgraded / customer.tier_downgradedA customer moves up or down a tier
program_events/customer.referral_completeA referral completes
program_events/customer.rule_completedA customer completes a rule
program_events/customer.approaching_tier_upgradeA customer is close to the next tier
program_events/customer.reward_available_notification / customer.recurring_reward_available_reminderA reward is available / a reminder for a recurring reward
program_events/customer.approaching_points_expiration / customer.approaching_reward_expirationPoints or a reward are about to expire
program_events/customer.moved_to_loyal_segment / moved_to_at_risk_segment / moved_to_defected_segmentA customer's insights segment changes
program_events/customer.receipt_approved / customer.receipt_declinedA 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

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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 12, 2026

View on GitHub →