Integrations
Webhooks
Be told within seconds when somebody registers, pays, walks in or deletes their data, in a body signed the Standard Webhooks way so you know it was us.
How they work
A webhook endpoint is an https address of yours that AttendBeacon calls with a POST when something happens at your events. Add one in the workspace under Integrations › Webhooks, or through the API (POST /webhooks), choose the event types it receives, and keep the signing secret it shows once.
AttendBeacon writes each event to a queue in its database first, then calls every endpoint that listens, so a restart on our side never loses one. Each call carries the same message id on every attempt, so a repeat can be recognised.
The event types
| Type | Sent when | Subscribing needs |
|---|---|---|
attendee.registered | A registration was created. | attendees:read people:read |
attendee.updated | A registration was changed. | attendees:read people:read |
attendee.cancelled | A registration was cancelled. | attendees:read people:read |
attendee.erased | An attendee deleted their data. | attendees:read |
order.paid | An order was paid. | orders:read people:read |
checkin.created | Somebody was let in. | checkins:read people:read |
feedback.given | A rating was given. | surveys:read people:read |
lead.captured | A lead was captured. | sponsors:read people:read |
certificate.issued | A certificate was issued. | attendees:read |
content.published | Session slides were published. | program:read |
member.joined | Somebody joined a community. | communities:read |
member.left | A member left a community. | communities:read |
stay.requested | A hotel stay was asked for. | events:read |
stay.assigned | A hotel stay was assigned. | events:read |
stay.changed | A hotel stay changed. | events:read |
stay.cancelled | A hotel stay was cancelled. | events:read |
shift.signed_up | A volunteer took a shift. | crew:read |
shift.dropped | A volunteer came off a shift. | crew:read |
travel.changed | A guest's travel changed. | events:read |
claim.submitted | An expense claim was sent in. | finance:read |
claim.approved | An expense claim was approved. | finance:read |
claim.paid | An expense claim was paid. | finance:read |
Webhook events in the reference shows each type's fields and an example. GET /webhooks/event-types answers the same list with each payload's JSON schema, for code that wants to check what it receives.
What arrives
POST /attendbeacon HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: msg_2f6c1a9e4b7d4c0f8e3a5b9d1c7e0f42
webhook-timestamp: 1792130400
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"type": "attendee.registered",
"timestamp": "2026-10-14T12:00:00.000Z",
"data": {
"event": { "eventId": "b3ac0000-0000-4000-8000-000000000001", "slug": "beacon-summit", "name": "Beacon Summit" },
"attendee": {
"attendeeId": "b3ac0008-0000-4000-8000-000000000000",
"ticketTypeName": "Conference pass",
"firstName": "Emma",
"lastName": "Bakker",
"email": "emma.bakker0@example.com",
"status": "active",
"valid": true
}
}
}The body is { type, timestamp, data }. An attendee in data has the same fields as the attendee the API answers, so code written for one works for the other. Types about hotel stays, travel, claims, shifts, certificates, content and community members carry ids, statuses, dates and amounts only, never a name.
Answer with any 2xx within 15 seconds. Do the work afterwards: put the message on a queue of your own and answer at once.
Checking the signature
Anybody can post to your address, so check every call before you trust it. The signature is the Base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body} under your endpoint's secret (the part after whsec_, Base64-decoded), written v1, and the signature. While a secret is being replaced the header may hold two, separated by a space; accept the call when one matches.
- Check the raw body, exactly as it arrived, before any JSON parsing.
- Refuse a timestamp more than five minutes from your clock, so a recorded call cannot be replayed later.
- Compare in constant time.
- Remember message ids you have handled, and skip a repeat.
import { createHmac, timingSafeEqual } from "node:crypto";
// secret: "whsec_..." as the workspace showed it; body: the raw request body as a string.
export function verify(secret, headers, body) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
return headers["webhook-signature"].split(" ").some((candidate) => {
const [version, signature] = candidate.split(",");
return version === "v1" && signature.length === expected.length
&& timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
});
}using System.Security.Cryptography;
using System.Text;
// secret: "whsec_..." as the workspace showed it; body: the raw request body as a string.
static bool Verify(string secret, string id, string timestamp, string signatures, string body)
{
if (!long.TryParse(timestamp, out var seconds) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 300) return false;
var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret);
var expected = Encoding.ASCII.GetBytes("v1," + Convert.ToBase64String(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}"))));
return signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Any(candidate => CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(candidate), expected));
}import base64, hashlib, hmac, time
# secret: "whsec_..." as the workspace showed it; body: the raw request body as bytes.
def verify(secret: str, headers: dict, body: bytes) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(candidate, expected) for candidate in headers["webhook-signature"].split(" "))# $secret: "whsec_..." as the workspace showed it; $body: the raw request body as a string.
function Test-AttendBeaconSignature($secret, $id, $timestamp, $signatures, $body) {
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
if ([math]::Abs($now - [long]$timestamp) -gt 300) { return $false }
$hmac = [System.Security.Cryptography.HMACSHA256]::new([Convert]::FromBase64String($secret -replace '^whsec_', ''))
$hash = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes("$id.$timestamp.$body"))
$expected = [Text.Encoding]::ASCII.GetBytes("v1," + [Convert]::ToBase64String($hash))
foreach ($candidate in $signatures -split ' ') {
if ([System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals([Text.Encoding]::ASCII.GetBytes($candidate), $expected)) { return $true }
}
return $false
}The scheme is Standard Webhooks, so its libraries for your language verify our calls as they are, with the same secret.
Retries
A call that does not answer 2xx within 15 seconds is tried again, eight times over a little under a day: at once, after 10 seconds, a minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 15 hours. Every answer other than 2xx is retried, a 4xx too: an endpoint that answers 401 after a bad deploy is usually fixed by the morning.
The exception is 410 Gone: it switches the endpoint off, because the receiver said it no longer exists. Switch it on again in the workspace when it does.
Each endpoint's page in the workspace lists its deliveries for 30 days: what was sent, every attempt and what the endpoint answered, with Resend for one that failed. Send test sends a signed webhook.test while you watch.
Endpoints through the API
With webhooks:write a key can add, change, test and remove endpoints itself, which suits software that sets up its own: POST /webhooks answers the endpoint and its secret, once; POST /webhooks/{webhookId}/secret makes a new one and stops the old one at once. A workspace has at most 20 endpoints.
Automation platforms
Power Automate, Zapier and Make subscribe through POST /hooks instead, one subscription per flow, and receive the same events as plain JSON with data lifted to the top: { id, type, timestamp, event, attendee }. They need no secret: the address they give is their own secret. See Power Automate and Zapier.