Webhooks
Webhooks push what happens on the platform — orders, scans, refunds, disputes, waitlists, cancelled sessions — to your endpoint as it happens. Every delivery is HMAC-signed, retried with backoff, and recorded in a delivery log you can inspect in the console.
Subscribing
In the console, open Delivery → Webhooks (multi-factor-verified session required). A subscription is an endpoint URL plus the set of events it wants, and it can cover your whole organisation or be scoped to a single event.
- The signing secret (
whsec_…) is shown once, at creation — store it like a password. - An event-scoped subscription only ever receives deliveries belonging to its event. Account-level events with no event attached — like
charge.disputed— go to organisation-wide subscriptions only. - Event matching is exact — there are no wildcards; pick each event explicitly.
Endpoint requirements
- A publicly resolvable URL, at most 2048 characters, with no credentials in it (
user:pass@is rejected). HTTPS is strongly recommended; plain HTTP is accepted. - The host must resolve to a public address — loopback, private-range and cloud-metadata targets are rejected, both when the subscription is created and again at every delivery.
- Redirects are not followed — the endpoint must answer directly.
The delivery
A delivery is an HTTP POST with a JSON envelope: the event name, when it was created, the api_version the payload was rendered under, and the event’s payload under data. JSON key order is not significant and varies between deliveries — read by key, never by position.
{
"event": "order.created",
"created_at": "2026-08-14T19:04:11+01:00",
"api_version": "2026-08-01",
"data": {
"order": "ord_01k1p3v9qe6rwyx0m5h8t2c4nf",
"status": "paid",
"currency": "GBP",
"total": 8250,
"application_fee": 165,
"event": "evt_01k0zqj4d8v6snb2x9e7g3m5cr",
"contact_email": "amelia.hart@example.com",
"tickets": 3
}
}X-Frequentix-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>X-Frequentix-EventThe event name, e.g. order.createdX-Frequentix-DeliveryThe delivery reference (whd_…) — stable across retriesContent-Typeapplication/jsonYour endpoint has 15 seconds to respond. Any 2xx counts as delivered; anything else — including a timeout — counts as a failed attempt.
Verifying the signature
The signature header carries a unix timestamp and a hex HMAC: t=<seconds>,v1=<signature>. The signature is HMAC-SHA256 over the timestamp, a literal dot, and the raw request body, keyed with your subscription’s secret. A complete worked example — which this site’s test suite recomputes and verifies on every build:
# The signing secret — shown once when the subscription was created
secret = whsec_9fJq2mX7RtA4LcW8zN3pYv6KdB5hUgS1eT0aQrHx
# Timestamp and raw body of the delivery
t = 1786730651
body = {"data":{"event":"evt_01k0zqj4d8v6snb2x9e7g3m5cr","order":"ord_01k1p3v9qe6rwyx0m5h8t2c4nf","total":8250,"status":"paid","tickets":3,"currency":"GBP","contact_email":"amelia.hart@example.com","application_fee":165},"event":"order.created","created_at":"2026-08-14T19:04:11+01:00","api_version":"2026-08-01"}
# signature = hex( HMAC-SHA256( secret, "{t}.{body}" ) )
X-Frequentix-Signature: t=1786730651,v1=722e85f919583b00ee5a48ee2f406a3f7ff7ca45978c26c44b5c0ccea4a5ad84- Verify over the raw bytes. Parse the JSON for your handler, but compute the HMAC against the body exactly as received — a re-serialisation can differ in whitespace, key order or escaping, and the signature covers exact bytes. Read the body before any JSON middleware touches it.
- Reject stale timestamps. Each attempt is signed freshly, so tolerate a small window — we recommend 5 minutes — and refuse anything older; that closes replay.
- Compare in constant time —
timingSafeEqual,hash_equals,compare_digest,fixed_length_secure_compare— never==.
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.FREQUENTIX_WEBHOOK_SECRET; // whsec_…
// express.raw keeps the body as the exact bytes that were signed —
// re-serialising parsed JSON would break the signature.
app.post(
"/webhooks/frequentix",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.header("X-Frequentix-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split("=", 2)),
);
const timestamp = Number(parts.t);
const expected = String(parts.v1 ?? "");
// Each attempt is signed freshly, so a 5-minute tolerance blocks replays.
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(400).send("Stale signature");
}
const digest = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(req.body) // Buffer of the raw body
.digest("hex");
const valid =
expected.length === digest.length &&
crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(expected));
if (!valid) return res.status(400).send("Invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
const deliveryId = req.header("X-Frequentix-Delivery"); // stable across retries
res.status(204).end(); // acknowledge fast…
handleAsync(event, deliveryId); // …then do the real work asynchronously
},
);<?php
$secret = getenv('FREQUENTIX_WEBHOOK_SECRET'); // whsec_…
// php://input is the raw body exactly as signed — never re-encode parsed JSON.
$body = file_get_contents('php://input');
// The X-Frequentix-Signature header, as PHP surfaces it.
$header = $_SERVER['HTTP_X_FREQUENTIX_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$expected = (string) ($parts['v1'] ?? '');
// Each attempt is signed freshly, so a 5-minute tolerance blocks replays.
if (abs(time() - $timestamp) > 300) {
http_response_code(400);
exit;
}
$digest = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
if (! hash_equals($digest, $expected)) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// X-Frequentix-Delivery is stable across retries — your dedupe key.
$deliveryId = $_SERVER['HTTP_X_FREQUENTIX_DELIVERY'] ?? null;
http_response_code(204); // acknowledge fast, then queue the real workimport hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["FREQUENTIX_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/webhooks/frequentix")
def frequentix_webhook():
# get_data() is the raw body exactly as signed — never re-encode parsed JSON.
body = request.get_data()
header = request.headers.get("X-Frequentix-Signature", "")
parts = dict(pair.split("=", 1) for pair in header.split(",") if "=" in pair)
timestamp = int(parts.get("t", "0"))
expected = parts.get("v1", "")
# Each attempt is signed freshly, so a 5-minute tolerance blocks replays.
if abs(time.time() - timestamp) > 300:
return "Stale signature", 400
digest = hmac.new(SECRET, f"{timestamp}.".encode() + body, hashlib.sha256)
if not hmac.compare_digest(digest.hexdigest(), expected):
return "Invalid signature", 400
event = json.loads(body)
delivery_id = request.headers.get("X-Frequentix-Delivery") # stable across retries
handle_async(event, delivery_id) # acknowledge fast, work asynchronously
return "", 204require "json"
require "openssl"
require "sinatra"
SECRET = ENV.fetch("FREQUENTIX_WEBHOOK_SECRET") # whsec_…
post "/webhooks/frequentix" do
# request.body is the raw body exactly as signed — never re-encode parsed JSON.
body = request.body.read
# The X-Frequentix-Signature header, as Rack surfaces it.
header = request.env["HTTP_X_FREQUENTIX_SIGNATURE"].to_s
parts = header.split(",").to_h { |pair| pair.split("=", 2) }
timestamp = parts["t"].to_i
expected = parts["v1"].to_s
# Each attempt is signed freshly, so a 5-minute tolerance blocks replays.
halt 400, "Stale signature" if (Time.now.to_i - timestamp).abs > 300
digest = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{timestamp}.#{body}")
unless expected.bytesize == digest.bytesize &&
OpenSSL.fixed_length_secure_compare(digest, expected)
halt 400, "Invalid signature"
end
event = JSON.parse(body)
# X-Frequentix-Delivery is stable across retries — your dedupe key.
delivery_id = request.env["HTTP_X_FREQUENTIX_DELIVERY"]
status 204 # acknowledge fast, then queue the real work
endRetries and failure
A failed delivery is retried up to 8 attempts, with the gap growing each time: 10 s, 30 s, 2 min, 5 min, 15 min, 1 h, then 2 h — about three and a half hours end to end. Every attempt is recorded on the delivery: its status, the response code, and the first 2,000 characters of the response body, all visible in the console’s delivery log.
When the final attempt fails, the subscription is auto-disabled with the reason stored, and the organiser is emailed. Re-enabling it in the console clears the reason and resumes delivery for new events — missed events are not replayed, so reconcile over the REST API after an outage.
Consuming reliably
- Acknowledge fast, process async. Verify, enqueue, return
2xx— well inside the 15-second window. Slow handlers become failed attempts, and repeated failure disables the subscription. - Deduplicate on
X-Frequentix-Delivery. The reference is stable across retries of the same delivery, so a retry after a timeout you actually processed is detectable. (The timestamp and signature are fresh on each attempt.) - Don’t assume order. Deliveries can arrive out of sequence; use the payload’s own markers —
revisiononticket.updated,statuson waitlist events — rather than arrival order. - Return 2xx even for events you ignore. Filtering belongs in your handler, not in refusing deliveries.
