Webhook payloads and signatures
The reference for whoever is building the receiving end — headers, the signature check, and the exact JSON for each of the four topics.
لم يُترجم هذا الدليل بعد، لذا يظهر بالإنجليزية.
This is the developer-facing half of Send your event data to another system. Set the webhook up on that page first; everything below describes what arrives at your endpoint.
The request
Every event is a single POST with a JSON body.
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: evenclo-webhooks/1
X-Evenclo-Topic: registration.created
X-Evenclo-Timestamp: 1754812462
X-Evenclo-Signature: sha256=a3f1c0…
{
"id": "0f5b6a2e-9c31-4a77-9d5e-2c3f1b8e4a90",
"topic": "registration.created",
"sent_at": "2026-08-10T09:14:22.104Z",
"data": { }
}
id is unique per delivery. topic is repeated in the header so you can route before parsing. data is the part that differs by topic.
Answer with any 2xx. Anything else, and anything slower than five seconds, is recorded as a failure on the webhook and not retried.
Redirects are not followed. A 301 or 302 counts as a failure — the checks we ran against your URL when it was saved would mean nothing if we then followed it somewhere else. Give us the final URL.
Verifying the signature
The URL is the only thing keeping strangers out, so check the signature before you trust a payload.
X-Evenclo-Signature is sha256= followed by the hex HMAC-SHA256 of the timestamp, a full stop, and the raw request body, keyed with the signing secret shown when the webhook was created.
import { createHmac, timingSafeEqual } from 'crypto';
// `raw` must be the body EXACTLY as it arrived — a Buffer or string, not an
// object you re-serialized. In Express: express.raw({ type: 'application/json' })
export function verify(raw, headers, secret) {
const ts = headers['x-evenclo-timestamp'];
const sig = headers['x-evenclo-signature'] || '';
// Reject anything older than five minutes, so a delivery someone captured
// cannot be replayed at you next week.
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const mine = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${raw}`).digest('hex');
const a = Buffer.from(mine), b = Buffer.from(sig);
return a.length === b.length && timingSafeEqual(a, b); // not ===
}
The same three lines in Python:
import hmac, hashlib, time
def verify(raw: bytes, headers, secret: str) -> bool:
ts = headers.get("X-Evenclo-Timestamp", "")
if not ts or abs(time.time() - int(ts)) > 300:
return False
mine = "sha256=" + hmac.new(
secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256
).hexdigest()
return hmac.compare_digest(mine, headers.get("X-Evenclo-Signature", ""))
If you are receiving through Zapier or Make, verifying is awkward — they hand you a parsed object, not the raw bytes. In practice you rely on the URL being unguessable instead. That is a real trade-off, so make it knowingly rather than by accident, and never treat an unverified payload as authorization for anything.
The attendee object
Three of the four topics carry the same registrant object, so a receiver only has to learn it once.
{
"id": "b1e7…",
"email": "sara@example.com",
"full_name": "Sara Halim",
"phone": "+201234567890",
"status": "confirmed",
"attendee_type_id": "5c2a…",
"qr_code": "9f31…",
"checked_in_at": null,
"created_at": "2026-08-10T09:14:21.880Z"
}
status is one of confirmed, pending, waitlisted, cancelled or rejected. qr_code is the attendee's badge credential — it is what a scanner reads, so treat it as you would a password and never put it in a URL you log.
Fields not listed above are never sent, whatever else is on the record.
registration.created
Fires once a registration is real. Not when a checkout begins — a paid ticket fires when the payment settles, so you never receive a registration that later evaporates.
{
"registrant": { },
"order_id": "77c1…",
"source": "organizer"
}
order_id is present only for a paid registration. source is present only when it did not come from the public form: organizer (added by hand in the dashboard) or invitation (a guest accepted). A plain self-registration carries neither key.
A CSV import does not fire this. Importing 500 rows would mean 500 deliveries in a few seconds.
checkin.created
Fires on arrival only — never on check-out, an undone scan, or a scan that was refused.
{
"registrant": { },
"reentry": false,
"gate": "North entrance",
"method": "scan"
}
method is scan (a badge QR), manual (found by name at the desk) or sync (a scan taken while the check-in device was offline, replayed when it reconnected — so sent_at can be well after the person actually walked in; registrant.checked_in_at holds the real arrival time).
reentry is true when this person had already checked in and out. gate is whatever the door was named, or null.
lead.created
Fires for every lead captured by any exhibitor at your event.
{
"exhibitor": { "id": "3a9f…", "company_name": "Northwind Systems" },
"lead": {
"id": "aa41…",
"registrant_id": "b1e7…",
"rating": 4,
"temperature": "hot",
"notes": "Wants a quote for 40 units.",
"contact": { "full_name": "Sara Halim", "email": "sara@example.com" },
"answers": { "Budget": "50k+", "Timeline": "This quarter" },
"captured_at": "2026-08-10T11:02:04.510Z",
"updated_at": "2026-08-10T11:06:12.221Z"
}
}
contact holds only the fields the attendee agreed to share. answers is keyed by your qualifier question labels. temperature is hot, warm or cold.
A lead is captured on scan and then edited as the booth staffer fills in the form, so you may receive one lead more than once — match on lead.id and take the latest updated_at.
survey.response
{
"response_id": "d02c…",
"survey": { "id": "e77a…", "title": "Day one feedback", "kind": "survey", "anonymous": false },
"registrant": { "id": "b1e7…", "full_name": "Sara Halim" },
"answers": [
{ "question_id": "1a2b…", "value": "Very useful" },
{ "question_id": "3c4d…", "value": 5 }
]
}
registrant is null whenever the survey is anonymous — and it stays null, so there is nothing on your side to be careful with. It carries no email even when named: match it to the person by id against the registration.created you already received.
kind is survey or poll. value is a string, a number, or an array for a multi-select.
Retries, ordering and duplicates
There are none of the first, no guarantee of the second, and you should expect the third.
Nothing is retried. A minute of downtime is a minute of lost events. Treat the feed as a live convenience and reconcile from a CSV export or the reports if you need certainty.
Order is not guaranteed. Two people registering in the same second can arrive in either order.
Deliveries can repeat — the same lead as it is edited, or a check-in replayed from an offline queue. Make your handler idempotent: for leads use lead.id, for everything else the delivery id.
Our IP addresses are not fixed. evenclo runs on Cloud Run, so an IP allowlist on your side will break without warning. Verify the signature instead.