Webhooks

Fideliya sends an HTTPS POST to your endpoint when something happens in your business: a card created, a customer joined, a scan at the counter. Version 2026-11 follows the Standard Webhooks specification, so a maintained verifier exists for your language.

Every delivery

The body is one JSON object. data depends on type (see Events below). JSON Schema.

{
  "id": "evt_01JC4Y7ZK3M8Q2W5R9T6V1X0AB",
  "type": "customer.created",
  "api_version": "2026-11",
  "created_at": "2026-11-03T10:15:00.000Z",
  "livemode": true,
  "business_id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "data": {
    "customer_id": "c1f0e2d3-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
    "customer_pass_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "pass_id": "7f1d5c1e-2b7a-4c1e-9d0e-0c6f3c2a9b10",
    "pass_name": "Café Lumière",
    "full_name": "Ana Ruiz",
    "email": "ana@example.com",
    "phone": null,
    "platform": "apple",
    "serial_number": "Zx81kq0LmP2a"
  }
}

With these headers:

webhook-id:        evt_…   the event id: the same on every retry and resend
webhook-timestamp: 1791203045   unix seconds, new on every attempt
webhook-signature: v1,<base64>   one or more, space-separated
content-type:      application/json

Verify every request

The signature is HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>, keyed with your secret (the part after whsec_, base64-decoded). Verify the raw body before parsing it, refuse a timestamp more than 300 seconds from your clock, then de-duplicate on webhook-id: a retry and a resend carry the same one. Every file below (Node with the library, Node by hand, Python by hand) is run by our tests against our own signer on every change.

verify-with-library.mjs

// Verify a Fideliya webhook with the Standard Webhooks library.
//   npm install standardwebhooks
//
// rawBody: the request body EXACTLY as it arrived (a string or Buffer, before
// any JSON parsing). headers: the request's headers, lower-case names.
import { Webhook } from 'standardwebhooks';

export function verifyFideliyaWebhook(rawBody, headers, secret = process.env.FIDELIYA_WEBHOOK_SECRET) {
  const wh = new Webhook(secret); // 'whsec_…', shown once when you created the endpoint
  // Throws if the signature does not match, or the timestamp is more than
  // 5 minutes away from now. Returns the parsed event.
  return wh.verify(rawBody, {
    'webhook-id': headers['webhook-id'],
    'webhook-timestamp': headers['webhook-timestamp'],
    'webhook-signature': headers['webhook-signature'],
  });
}

// Then de-duplicate on the event id: a retry and a resend carry the same one.
//   const event = verifyFideliyaWebhook(rawBody, req.headers);
//   if (await alreadyHandled(event.id)) return res.status(200).end();

verify-by-hand.mjs

// Verify a Fideliya webhook with Node's crypto only (no library).
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verifyFideliyaWebhook(rawBody, headers, secret = process.env.FIDELIYA_WEBHOOK_SECRET) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) throw new Error('missing webhook headers');

  // Refuse a stale or future timestamp: a replayed request is refused.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) throw new Error('timestamp outside tolerance');

  // The key is the base64 part of the secret, after 'whsec_'.
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();

  // Several signatures may be sent at once (during a secret rotation): any match is enough.
  for (const entry of String(signatures).split(' ')) {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) continue;
    const given = Buffer.from(value, 'base64');
    // timingSafeEqual throws on different lengths: compare lengths first.
    if (given.length === expected.length && timingSafeEqual(given, expected)) {
      return JSON.parse(String(rawBody));
    }
  }
  throw new Error('no valid signature');
}

verify-by-hand.py

# Verify a Fideliya webhook in Python with the standard library only (no package to install).
import base64
import hashlib
import hmac
import json
import os
import time

TOLERANCE_SECONDS = 300


def verify_fideliya_webhook(raw_body, headers, secret=None):
    """Return the event (a dict) if the request is genuine; raise ValueError otherwise.

    raw_body: the request body exactly as received (bytes or str), before any JSON parsing.
    headers: the request headers, with lower-case names.
    """
    secret = secret or os.environ["FIDELIYA_WEBHOOK_SECRET"]
    body = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        raise ValueError("missing webhook headers")

    # Refuse a stale or future timestamp: a replayed request is refused.
    try:
        age = abs(int(time.time()) - int(timestamp))
    except ValueError:
        raise ValueError("timestamp outside tolerance")
    if age > TOLERANCE_SECONDS:
        raise ValueError("timestamp outside tolerance")

    # The key is the base64 part of the secret, after 'whsec_'.
    key = base64.b64decode(secret[len("whsec_"):] if secret.startswith("whsec_") else secret)
    signed = f"{msg_id}.{timestamp}.{body}".encode("utf-8")
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    # Several signatures may be sent at once (during a secret rotation): any match is enough.
    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1" or not value:
            continue
        try:
            given = base64.b64decode(value)
        except ValueError:
            continue
        # compare_digest takes the same time whether or not the signatures match.
        if hmac.compare_digest(given, expected):
            return json.loads(body)
    raise ValueError("no valid signature")

Rotating the secret: for 24 hours after a rotation every delivery carries two signatures, one per secret, so you can switch without missing an event. Accept a request when any signature matches.

Retries, pauses, and healing

  • A delivery succeeds on any 2xx answered within 10 seconds. Redirects are not followed.
  • Otherwise it is retried: 8 attempts: the first at once, then after 1 min, 5 min, 30 min, 2 h, 5 h, 8 h, 8 h (each ±10 %), about 23.6 hours in all. A 429 with Retry-After is waited for, up to an hour. A 410 Gone stops at once.
  • An endpoint that times out 3 times in a row is not tried again until its next scheduled retry, so one slow receiver cannot hold up the queue.
  • An endpoint is paused after 3 days in which every attempt failed, and at least 20 attempts in a row. Events keep being recorded while it is paused.
  • A paused endpoint is checked with a signed webhook.ping at 1 h, 6 h, day 1, day 2, day 3, day 4, day 5, day 6, day 7 after the pause. After 2 answers in a row it resumes by itself, and what it missed is sent again, oldest first. Answer webhook.ping with a 2xx.
  • You can also resume it yourself from the dashboard: the last 72 hours are sent then, and any older event can be resent from the delivery log.
  • Events and their delivery log are kept 30 days.

Events

Subscribe to any of these in the dashboard. Each field list is generated from the schema our tests hold every real payload to. New optional fields may be added without a new version; ignore fields you do not know.

Deleted customers. When the business deletes a customer, their name, email and phone are removed from every stored event about them (set to null), so a resend never carries them. The event keeps its ids, counts and times, and says so: customer_deleted: true with customer_deleted_at (on a referral, referrer_deleted or referred_deleted, for that side only; on a gift card, recipient_deleted). Show “Deleted customer” rather than a blank, and remove the person from your own system too.

pass.created

A card design was created. JSON Schema.

pass_idstringThe new card design (pass) id.
pass_typestringThe pass style: generic, storeCard, ...
business_namestring | null
loyalty_type"visits" | "points" | null
loyalty_goalnumber | nullVisits or points to a reward.
share_tokenstring | nullThe token in the card's public join link.
Example data
{
  "pass_id": "7f1d5c1e-2b7a-4c1e-9d0e-0c6f3c2a9b10",
  "pass_type": "storeCard",
  "business_name": "Café Lumière",
  "loyalty_type": "visits",
  "loyalty_goal": 10,
  "share_token": "3b9c1f0a-6d2e-4f1b-8a7c-5e4d3c2b1a09"
}

customer.created

A customer joined a card (added it to their wallet). JSON Schema.

customer_idstring
customer_pass_idstringThe customer's card: one per customer per pass.
pass_idstring
pass_namestring | null
full_namestring | null
emailstring | null
phonestring | null
platformstring | nullapple, google, or web.
serial_numberstring
customer_deletedtruePresent, and true, once the business deleted this customer: their name and contact details are removed from this event (null), and a resend carries none. Show "Deleted customer".
customer_deleted_atstring (ISO 8601 time)When the business deleted this customer. Present with the flag above.
Example data
{
  "customer_id": "c1f0e2d3-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
  "customer_pass_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "pass_id": "7f1d5c1e-2b7a-4c1e-9d0e-0c6f3c2a9b10",
  "pass_name": "Café Lumière",
  "full_name": "Ana Ruiz",
  "email": "ana@example.com",
  "phone": null,
  "platform": "apple",
  "serial_number": "Zx81kq0LmP2a"
}

scan.recorded (v1: pass.scanned)

A card was scanned at the counter: a visit, points, a redemption or a reward claim. JSON Schema.

pass_idstring
customer_idstring
customer_pass_idstring
customer_namestring | null
action"visit" | "points" | "redeem" | "claim_reward"What the counter did. Redemptions and claims are scans too.
loyalty_type"visits" | "points" | null
loyalty_goalnumber | null
points_addednumber
points_deductednumber
visit_addednumber
new_valuenumber | nullThe card's visits or points after the scan.
rewards_availablenumber | null
location_idstring | null
amountnumber | nullThe till amount entered with a points scan, if any.
referral_completedboolean
timestampstring (ISO 8601 time)
customer_deletedtruePresent, and true, once the business deleted this customer: their name and contact details are removed from this event (null), and a resend carries none. Show "Deleted customer".
customer_deleted_atstring (ISO 8601 time)When the business deleted this customer. Present with the flag above.
Example data
{
  "pass_id": "7f1d5c1e-2b7a-4c1e-9d0e-0c6f3c2a9b10",
  "customer_id": "c1f0e2d3-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
  "customer_pass_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "customer_name": "Ana Ruiz",
  "action": "visit",
  "loyalty_type": "visits",
  "loyalty_goal": 10,
  "points_added": 0,
  "points_deducted": 0,
  "visit_added": 1,
  "new_value": 4,
  "rewards_available": 0,
  "location_id": null,
  "amount": 0,
  "referral_completed": false,
  "timestamp": "2026-11-03T10:15:00.000Z"
}

scan.voided

A scan was undone at the counter within seconds of it (a mistake): the stamp, reward, claim or gift card spend is taken back, and the card is as it was before. JSON Schema.

receipt_idstringThe scan that was undone (its receipt).
kind"visit" | "redeem" | "claim_reward" | "gift_card_spend"What was undone: a stamp, a reward given, a points reward claimed, or a gift card spend.
pass_idstring | null
customer_idstring | null
customer_pass_idstring | nullThe customer's card, for a loyalty scan.
gift_card_idstring | nullThe gift card, for a spend.
new_valuenumber | nullThe card's visits or points after the undo.
rewards_availablenumber | null
balancenumber | nullThe gift card's balance after the undo.
scanned_atstring (ISO 8601 time)When the scan that was undone happened.
timestampstring (ISO 8601 time)
customer_deletedtruePresent, and true, once the business deleted this customer: their name and contact details are removed from this event (null), and a resend carries none. Show "Deleted customer".
customer_deleted_atstring (ISO 8601 time)When the business deleted this customer. Present with the flag above.
Example data
{
  "receipt_id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
  "kind": "visit",
  "pass_id": "7f1d5c1e-2b7a-4c1e-9d0e-0c6f3c2a9b10",
  "customer_id": "c1f0e2d3-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
  "customer_pass_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "gift_card_id": null,
  "new_value": 3,
  "rewards_available": 0,
  "balance": null,
  "scanned_at": "2026-11-03T10:15:00.000Z",
  "timestamp": "2026-11-03T10:15:04.000Z"
}

milestone.reached (v1: milestone.earned)

A customer reached a reward on their card. JSON Schema.

pass_idstring
customer_idstring
customer_pass_idstring
customer_namestring | null
milestonenumberThe card's total milestones after this one.
reward_namestring | null
loyalty_type"visits" | "points" | null
loyalty_goalnumber | null
new_valuenumber | null
timestampstring (ISO 8601 time)
customer_deletedtruePresent, and true, once the business deleted this customer: their name and contact details are removed from this event (null), and a resend carries none. Show "Deleted customer".
customer_deleted_atstring (ISO 8601 time)When the business deleted this customer. Present with the flag above.
Example data
{
  "pass_id": "7f1d5c1e-2b7a-4c1e-9d0e-0c6f3c2a9b10",
  "customer_id": "c1f0e2d3-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
  "customer_pass_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "customer_name": "Ana Ruiz",
  "milestone": 1,
  "reward_name": "Free coffee",
  "loyalty_type": "visits",
  "loyalty_goal": 10,
  "new_value": 10,
  "timestamp": "2026-11-03T10:15:00.000Z"
}

referral.completed (v1: referral_completed)

A referred friend made their first visit, so both earned their bonus. JSON Schema.

pass_idstring
pass_namestring
referrer_customer_pass_idstring
referrer_namestring | nullNull only once the business deleted the referrer (see referrer_deleted).
referred_customer_pass_idstring
referred_namestring | nullNull only once the business deleted the friend (see referred_deleted).
referrer_bonusnumber
new_customer_bonusnumber
loyalty_type"visits" | "points"
timestampstring (ISO 8601 time)
referrer_deletedtruePresent, and true, once the business deleted the referrer: their name and contact details are removed from this event (null), and a resend carries none. Show "Deleted customer".
referrer_deleted_atstring (ISO 8601 time)When the business deleted the referrer. Present with the flag above.
referred_deletedtruePresent, and true, once the business deleted the friend: their name and contact details are removed from this event (null), and a resend carries none. Show "Deleted customer".
referred_deleted_atstring (ISO 8601 time)When the business deleted the friend. Present with the flag above.
Example data
{
  "pass_id": "7f1d5c1e-2b7a-4c1e-9d0e-0c6f3c2a9b10",
  "pass_name": "Café Lumière",
  "referrer_customer_pass_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "referrer_name": "Ana",
  "referred_customer_pass_id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
  "referred_name": "Sam",
  "referrer_bonus": 1,
  "new_customer_bonus": 1,
  "loyalty_type": "visits",
  "timestamp": "2026-11-03T10:15:00.000Z"
}

gift_card.sent (v1: gift_card_sent)

A gift card email was accepted for delivery (issued, or resent). JSON Schema.

gift_card_idstring
template_idstring | null
recipient_emailstring | null
recipient_namestring | null
amountnumber
balancenumber
currencystring
localestring | null
statusstring
resendbooleanTrue when this is a resend of a card already issued.
timestampstring (ISO 8601 time)
recipient_deletedtruePresent, and true, once the business deleted the recipient (a customer of theirs): their name and contact details are removed from this event (null), and a resend carries none. Show "Deleted customer".
recipient_deleted_atstring (ISO 8601 time)When the business deleted the recipient. Present with the flag above.
Example data
{
  "gift_card_id": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a",
  "template_id": null,
  "recipient_email": "sam@example.com",
  "recipient_name": "Sam",
  "amount": 25,
  "balance": 25,
  "currency": "EUR",
  "locale": "en",
  "status": "active",
  "resend": false,
  "timestamp": "2026-11-03T10:15:00.000Z"
}

gift_card.spent

A payment was taken from a gift card, at the counter or through the API. Undone within seconds at the till, it is followed by scan.voided (kind gift_card_spend). JSON Schema.

gift_card_idstring
transaction_idstringThe payment: the row in the card's ledger (GET /v1/gift_cards/{id}, `transactions`).
amountnumberWhat was taken, in the card's currency (sessions for a SESSIONS card).
balancenumberWhat is left on the card after it.
currencystringEUR, MAD, AED, QAR, or SESSIONS.
statusstring`active`, or `depleted` when this payment used the card up.
location_idstring | nullThe counter it was taken at, when known.
referencestring | nullThe till's own reference for the payment, when the API call gave one.
source"scanner" | "api"Taken at the counter with the scanner, or by your system through an API key.
timestampstring (ISO 8601 time)
Example data
{
  "gift_card_id": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a",
  "transaction_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "amount": 12.5,
  "balance": 12.5,
  "currency": "EUR",
  "status": "active",
  "location_id": null,
  "reference": "T-1042",
  "source": "api",
  "timestamp": "2026-11-03T10:15:00.000Z"
}

subscription.created (v1: subscription_created)

Your Fideliya subscription started (an account event: every endpoint on the account receives it). JSON Schema.

tierstring
statusstring
priceIdstring
currentPeriodStartstring (ISO 8601 time) | null
currentPeriodEndstring (ISO 8601 time) | null
isTrialboolean
Example data
{
  "tier": "enterprise",
  "status": "active",
  "priceId": "price_123",
  "currentPeriodStart": "2026-11-01T00:00:00.000Z",
  "currentPeriodEnd": "2026-12-01T00:00:00.000Z",
  "isTrial": false
}

subscription.cancelled (v1: subscription_cancelled)

Your Fideliya subscription was cancelled (an account event). JSON Schema.

stripe_subscription_idstring
canceled_atstring (ISO 8601 time)
Example data
{
  "stripe_subscription_id": "sub_123",
  "canceled_at": "2026-11-03T10:15:00.000Z"
}

webhook.test

Sent by "Send test event" and when an endpoint is upgraded to v2. Never sent by a real action; livemode is false. JSON Schema.

webhook_idstringThe endpoint this test was sent to.
messagestring
Example data
{
  "webhook_id": "0e1f2a3b-4c5d-4e6f-8a7b-9c0d1e2f3a4b",
  "message": "A test event from Fideliya."
}

webhook.ping

A health check sent to an endpoint paused after failures: 1 hour, 6 hours, then daily, for 7 days. Two 2xx answers in a row resume the endpoint and replay what it missed. Not stored, not in the delivery log, never subscribed to; answer it 2xx. JSON Schema.

webhook_idstringThe paused endpoint being checked.
reason"health_probe"
Example data
{
  "webhook_id": "0e1f2a3b-4c5d-4e6f-8a7b-9c0d1e2f3a4b",
  "reason": "health_probe"
}

Endpoints created before 2026-11

An older endpoint receives the earlier format: { event, timestamp, data } with the event’s earlier name, signed with a hex HMAC-SHA256 of the body in X-Webhook-Signature. It gets the same retries, pausing and healing. Upgrade it from the dashboard: it gets a new whsec_ secret, shown once, and a test event in the new format.

Add to Apple WalletAdd to Google Wallet
© 2026 Fideliya. All rights reserved.
Webhooks | Fideliya developers