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

Signup event
{ "id": "EVENT_ID", "type": "signup.created", "version": 1, "occurredAt": "2026-10-11T00:00:00.000Z", "waitlistId": "WAITLIST_ID", "data": { "signupId": "SIGNUP_ID" } }
Referral and milestone events
{ "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" } }
EventWhen it firesdata shape
signup.createdIntake, before verification{ signupId }
signup.verifiedAfter email confirmation{ signupId }
referral.completedInviter 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.reachedSignup 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.

Enriched signup event (opt-in only)
{
  "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.

Verify before processing
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 v1 during 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.

Next

On this page