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-Afteris 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.pingat 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. Answerwebhook.pingwith 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_id | string | The new card design (pass) id. |
| pass_type | string | The pass style: generic, storeCard, ... |
| business_name | string | null | |
| loyalty_type | "visits" | "points" | null | |
| loyalty_goal | number | null | Visits or points to a reward. |
| share_token | string | null | The 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_id | string | |
| customer_pass_id | string | The customer's card: one per customer per pass. |
| pass_id | string | |
| pass_name | string | null | |
| full_name | string | null | |
| string | null | ||
| phone | string | null | |
| platform | string | null | apple, google, or web. |
| serial_number | string | |
| customer_deleted | true | Present, 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_at | string (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_id | string | |
| customer_id | string | |
| customer_pass_id | string | |
| customer_name | string | null | |
| action | "visit" | "points" | "redeem" | "claim_reward" | What the counter did. Redemptions and claims are scans too. |
| loyalty_type | "visits" | "points" | null | |
| loyalty_goal | number | null | |
| points_added | number | |
| points_deducted | number | |
| visit_added | number | |
| new_value | number | null | The card's visits or points after the scan. |
| rewards_available | number | null | |
| location_id | string | null | |
| amount | number | null | The till amount entered with a points scan, if any. |
| referral_completed | boolean | |
| timestamp | string (ISO 8601 time) | |
| customer_deleted | true | Present, 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_at | string (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_id | string | The 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_id | string | null | |
| customer_id | string | null | |
| customer_pass_id | string | null | The customer's card, for a loyalty scan. |
| gift_card_id | string | null | The gift card, for a spend. |
| new_value | number | null | The card's visits or points after the undo. |
| rewards_available | number | null | |
| balance | number | null | The gift card's balance after the undo. |
| scanned_at | string (ISO 8601 time) | When the scan that was undone happened. |
| timestamp | string (ISO 8601 time) | |
| customer_deleted | true | Present, 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_at | string (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_id | string | |
| customer_id | string | |
| customer_pass_id | string | |
| customer_name | string | null | |
| milestone | number | The card's total milestones after this one. |
| reward_name | string | null | |
| loyalty_type | "visits" | "points" | null | |
| loyalty_goal | number | null | |
| new_value | number | null | |
| timestamp | string (ISO 8601 time) | |
| customer_deleted | true | Present, 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_at | string (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_id | string | |
| pass_name | string | |
| referrer_customer_pass_id | string | |
| referrer_name | string | null | Null only once the business deleted the referrer (see referrer_deleted). |
| referred_customer_pass_id | string | |
| referred_name | string | null | Null only once the business deleted the friend (see referred_deleted). |
| referrer_bonus | number | |
| new_customer_bonus | number | |
| loyalty_type | "visits" | "points" | |
| timestamp | string (ISO 8601 time) | |
| referrer_deleted | true | Present, 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_at | string (ISO 8601 time) | When the business deleted the referrer. Present with the flag above. |
| referred_deleted | true | Present, 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_at | string (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_id | string | |
| template_id | string | null | |
| recipient_email | string | null | |
| recipient_name | string | null | |
| amount | number | |
| balance | number | |
| currency | string | |
| locale | string | null | |
| status | string | |
| resend | boolean | True when this is a resend of a card already issued. |
| timestamp | string (ISO 8601 time) | |
| recipient_deleted | true | Present, 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_at | string (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_id | string | |
| transaction_id | string | The payment: the row in the card's ledger (GET /v1/gift_cards/{id}, `transactions`). |
| amount | number | What was taken, in the card's currency (sessions for a SESSIONS card). |
| balance | number | What is left on the card after it. |
| currency | string | EUR, MAD, AED, QAR, or SESSIONS. |
| status | string | `active`, or `depleted` when this payment used the card up. |
| location_id | string | null | The counter it was taken at, when known. |
| reference | string | null | The 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. |
| timestamp | string (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.
| tier | string | |
| status | string | |
| priceId | string | |
| currentPeriodStart | string (ISO 8601 time) | null | |
| currentPeriodEnd | string (ISO 8601 time) | null | |
| isTrial | boolean |
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_id | string | |
| canceled_at | string (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_id | string | The endpoint this test was sent to. |
| message | string |
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_id | string | The 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.