# Webhooks | Waitlyze Docs

> Receive signed signup, referral, and milestone events.

Canonical: https://waitlyze.com/docs/webhooks


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-an-endpoint]

<Steps>
<Step>
### Add a public HTTPS URL [#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.
</Step>
<Step>
### Copy the signing secret [#copy-the-signing-secret]
Save it in your secrets manager immediately. You can copy it again any time from the endpoint's ⋯ menu.
</Step>
<Step>
### Send a test before going live [#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.
</Step>
</Steps>

<Callout title="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.
</Callout>

<Callout title="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](/docs/troubleshooting). This repo ships a Cloudflare Worker cron for dispatch - see `workers/outbox-dispatch`.
</Callout>

## Event payload [#event-payload]

```json title="Signup event"
{ "id": "EVENT_ID", "type": "signup.created", "version": 1, "occurredAt": "2026-10-11T00:00:00.000Z", "waitlistId": "WAITLIST_ID", "data": { "signupId": "SIGNUP_ID" } }
```

```json title="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" } }
```

| 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`.

```json title="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) [#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](/docs/notifications) for milestone pings.

<TypeTable
  type={{
    id: {
      type: "string",
      required: true,
      description: "Unique event ID. Persist it to deduplicate retried deliveries.",
    },
    type: {
      type: '"signup.created" | "signup.verified" | "referral.completed" | "milestone.reached"',
      required: true,
      description: "signup.created fires on intake; signup.verified after email confirmation; referral.completed when the invitee verifies and the referral is CLEAR/APPROVED (same gate as the points ledger); milestone.reached at 1/10/100/1000 signups.",
    },
    version: {
      type: "number",
      required: true,
      description: "Payload version, currently 1.",
    },
    occurredAt: {
      type: "string",
      required: true,
      description: "ISO-8601 timestamp of the event.",
    },
    waitlistId: {
      type: "string",
      required: true,
      description: "The waitlist the signup belongs to.",
    },
    "data.signupId": {
      type: "string",
      required: true,
      description: "The signup record (the inviter for referral.completed; the referrer for reward-tier milestone.reached). Email is included only in optional data.subscriber when enabled. Test deliveries carry `{ test: true }` here instead. Milestone events carry `count` (+ optional `triggeringSignupId`) rather than a subject signup.",
    },
    "data.subscriber": {
      type: "object",
      required: false,
      description: "Opt-in subscriber snapshot with email, phone, customFields, verifiedAt, marketingConsent, referralCode, referralLink, position, points, referralCount, country, and utmSource/utmMedium/utmCampaign/utmTerm/utmContent. Test deliveries use sample values; events without a referenced signup omit it.",
    },
  }}
/>

## Verify the signature [#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.

```js title="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 [#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.

<Callout type="warn" title="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.
</Callout>

## Next [#next]

<Cards>
<Card title="Integrations" href="/docs/integrations/overview" description="Choose between webhooks, chat notifications, Resend, and Zapier." />
<Card title="Signup API" href="/docs/api" description="Create the signups webhooks report." />
<Card title="Subscribers" href="/docs/subscribers" description="Inspect the signup behind an event." />
<Card title="Troubleshooting" href="/docs/troubleshooting" description="Fix signature and delivery failures." />
</Cards>

## Related Links
- [API Reference](https://waitlyze.com/docs/api/reference) - REST API endpoints
- [Embed Widget](https://waitlyze.com/docs/embed) - Embed instructions
- [Waitlyze Home](https://waitlyze.com/) - Product overview
