Webhooks
Receive signed signup, referral, and milestone events.
Webhooks stream signup activity to your stack in real time. Manage them in Waitlist settings → Webhooks. Owners and admins can manage; members can view.
Add an endpoint
Add a public HTTPS URL
Enter your receiver URL. New endpoints subscribe to all four events - signup, referral, and milestone. Localhost and non-HTTPS URLs are rejected. Include subscriber data is optional and off by default; enable it only if you trust the receiver with personal data. You can change it later from the endpoint's View deliveries panel.
Copy the signing secret
Save it in your secrets manager immediately. You can copy it again any time from the endpoint's ⋯ menu.
Send a test before going live
Tests use { "test": true } in data and never represent a real subscriber. When enrichment is enabled, the test also includes a sample data.subscriber to map fields in Zapier, Make, or n8n. Confirm your receiver verifies and dedupes before processing real events.
Existing endpoints need a re-add for new events
Endpoints created before referral.completed and milestone.reached launched stay subscribed to signup events only. Add a new endpoint (it gets all four events) and pause the old one once the new receiver is verified.
Dispatch must be configured
Your deployment needs webhook encryption and dispatch configured before delivery runs. If tests never arrive, check deployment config first, then endpoint status. See Troubleshooting. This repo ships a Cloudflare Worker cron for dispatch - see workers/outbox-dispatch.
Event payload
{ "id": "EVENT_ID", "type": "signup.created", "version": 1, "occurredAt": "2026-10-11T00:00:00.000Z", "waitlistId": "WAITLIST_ID", "data": { "signupId": "SIGNUP_ID" } }{ "id": "EVENT_ID", "type": "referral.completed", "version": 1, "occurredAt": "2026-10-25T00:00:00.000Z", "waitlistId": "WAITLIST_ID", "data": { "signupId": "INVITER_SIGNUP_ID", "referredSignupId": "INVITEE_SIGNUP_ID" } }
{ "id": "EVENT_ID", "type": "milestone.reached", "version": 1, "occurredAt": "2026-10-25T00:00:00.000Z", "waitlistId": "WAITLIST_ID", "data": { "count": 100, "triggeringSignupId": "SIGNUP_ID" } }| Event | When it fires | data shape |
|---|---|---|
signup.created | Intake, before verification | { signupId } |
signup.verified | After email confirmation | { signupId } |
referral.completed | Inviter earns credit when the invitee verifies and the referral is CLEAR/APPROVED (same gate as the points ledger) | { signupId } = inviter, { referredSignupId } = invitee. Gate reward fulfillment on this, never on signup alone. Queue-position counting additionally requires the inviter to be verified - that rule lives in the position engine, not in this event. |
milestone.reached | Signup count hits 1, 10, 100, or 1000 - or a referrer crosses a reward-tier threshold | { count } plus triggeringSignupId when known. Tier unlocks add { reward } (the tier label) and use event ids shaped reward-tier:{waitlist}:{signup}:{count}, so they never collide with count milestones; there signupId is the referrer who earns the tier and triggeringSignupId is the invitee whose verification crossed it. Concurrent trips of the same milestone collapse to a single delivery, so dedupe by event id as usual. |
By default, subscriber emails are not included - fetch what you need via your subscriber read path using signupId. For an endpoint with Include subscriber data enabled, Waitlyze adds data.subscriber when a referenced signup exists. For referral.completed this is the inviter (signupId), not the invitee; for signup-count milestones it is triggeringSignupId when present. If there is no referenced signup or it was deleted, data.subscriber is omitted. Return 2xx only after durably accepting the event. Deduplicate by event ID because retries redeliver the same id.
{
"id": "EVENT_ID",
"type": "signup.verified",
"version": 1,
"occurredAt": "2026-11-06T09:00:00.000Z",
"waitlistId": "WAITLIST_ID",
"data": {
"signupId": "SIGNUP_ID",
"subscriber": {
"email": "ada@example.com",
"phone": "+15555550123",
"customFields": { "role": "Founder" },
"verifiedAt": "2026-11-06T09:00:00.000Z",
"marketingConsent": true,
"referralCode": "sample-referral",
"referralLink": "https://example.com/w/sample?r=sample-referral",
"position": 42,
"points": 230,
"referralCount": 5,
"country": "US",
"utmSource": "newsletter",
"utmMedium": "email",
"utmCampaign": "launch",
"utmTerm": null,
"utmContent": null
}
}
}Fields are resolved when the delivery is first attempted, not at signup time. Optional fields can be null (phone, customFields, verifiedAt, referralLink, position, country, and UTM fields). marketingConsent reflects current opt-in without an unsubscribe, and referralCount includes eligible verified referrals. Retry attempts reuse the saved payload. Turning enrichment off removes subscriber data from subsequent delivery attempts; earlier deliveries cannot be recalled. Protect receiver logs and access to the signed payload, especially when sending phone numbers or custom qualification answers.
Email events (coverage note)
There are no email.* webhook events. Resend delivery signals (delivered, bounced, complained) feed the workspace suppression list automatically - bounced or complained addresses are excluded from future broadcasts and automations - and per-send delivery is visible in broadcast reports, but they are not fanned out as customer webhooks. That gap is recorded, not shipped: adding events like broadcast.finished or email.bounced needs new enqueue call sites in the broadcast pipeline, which is frozen while founder-email work lands. Until then, poll broadcast delivery reports or watch Slack/Discord notifications for milestone pings.
Prop
Type
Verify the signature
The x-waitlyze-signature header looks like t=TIMESTAMP,v1=HEX_SIGNATURE. Verify HMAC-SHA256 over timestamp + "." + canonicalJson(payload) where canonical JSON is recursively key-sorted. Do not sign raw request bytes. During rotation there may be multiple v1 signatures - accept if any validates. Also check x-waitlyze-event-id matches id when present.
import { createHmac, timingSafeEqual } from "node:crypto";
function canonicalJson(value) {
if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
if (value && typeof value === "object") {
return "{" + Object.keys(value).sort().map(key =>
JSON.stringify(key) + ":" + canonicalJson(value[key])
).join(",") + "}";
}
return JSON.stringify(value);
}
export function verifyEvent(header, payload, secret, now = Date.now()) {
if (typeof header !== "string") return false;
const parts = header.split(",");
const timestamp = parts.find(part => part.startsWith("t="))?.slice(2);
if (!/^\d+$/.test(timestamp || "")) return false;
if (Math.abs(now / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + "." + canonicalJson(payload)).digest();
return parts.filter(part => part.startsWith("v1="))
.some(part => {
const signature = part.slice(3);
if (!/^[a-f0-9]{64}$/i.test(signature)) return false;
return timingSafeEqual(expected, Buffer.from(signature, "hex"));
});
}Reject requests outside the five-minute timestamp window. Verify before parsing business logic or mutating state. Use constant-time comparison.
Deliveries and recovery
Recent deliveries show queue status, HTTP response status, and a failure code when available:
- Send a test after any secret, URL, or firewall change.
- Pause an endpoint to stop queueing new events; resume when ready.
- Replay failed deliveries from the same settings screen after fixing your receiver.
- A waitlist can have up to five endpoints. Pause or repurpose one before adding another.
- Rotate the secret when staff changes or on a schedule; the previous secret stays valid for 24 hours, so accept any valid
v1during that window.
Handle repeat delivery safely
Deduplicate by id or x-waitlyze-event-id, verify the signature first, and return success only after durable acceptance. A test payload is not a real subscriber - do not create accounts from it.