# Webhooks > Get a signed HTTP POST the moment something happens on your waitlist, whether a signup, a verification or a referral, so your own systems react in real time. Source: https://getlaunchlist.com/help/docs/plugins/webhook --- A webhook is LaunchList telling your systems that something happened, the moment it happens. No polling, no delay. ## Adding an endpoint 1. Open **Embed & API → Developers → Webhooks**, press **New endpoint** (**Add your first endpoint** on an empty screen), enter your **Endpoint URL**, and press **Create endpoint**. The URL must be `https`. 2. Under **Events to send**, choose the events it should receive. Subscribe to what you will act on, not everything. 3. Open the endpoint's **Signing and verification** tab. Under **Signing secret**, press **Reveal**, then **Copy**. **Rotate** issues a new one without downtime if it leaks. ![The New endpoint panel with the endpoint URL field, a description, and the events to send.](https://app.getlaunchlist.com/images/docs/plugins/webhook/01-new-endpoint.webp "Add the URL and choose which events it receives.") ![An endpoint's Signing and verification tab, with the signing secret hidden and a Reveal button beside it.](https://app.getlaunchlist.com/images/docs/plugins/webhook/03-signing-secret.webp "Press Reveal to see the signing secret, then Copy.") Free includes **one** webhook endpoint; Collect, Launch and Grow allow up to 20 per workspace. If a workspace moves to a plan with fewer, the oldest endpoints inside the allowance keep delivering and the rest pause. None of them is deleted, and a paused endpoint delivers again when your plan covers it. ## What you can subscribe to The picker groups events exactly the way the dashboard does. Subscribe to the ones you will act on, or subscribe to **`*`** to receive everything, including event types added after you set the endpoint up. **Audience**: your contacts and what happened to them. | Event | Fires when | | ----------------------- | ------------------------------------------------- | | `signup.created` | Someone joins, through any channel | | `contact.verified` | A signup verifies their email address (they were already subscribed when they joined) | | `contact.stage_changed` | You move someone through your funnel | | `trust.changed` | Our real-versus-fake verdict for a contact moves | | `contact.removed` | A contact is deleted from the audience | | `contact.erased` | A right-to-be-forgotten request erases a contact | | `audience.imported` | A CSV import finishes | **Early access**: the invitation lifecycle. | Event | Fires when | | ----------------- | ------------------------------------------------------------- | | `invite.issued` | A code is minted. Write it to your own allowlist | | `invite.redeemed` | Someone used their code and is in. Carries your `external_id` | | `invite.revoked` | A code is taken back and will no longer work | | `invite.expired` | A code reached its expiry date and stopped accepting redemptions | **Referral**: the growth loop. | Event | Fires when | | ------------------ | ------------------------------------------ | | `referral.made` | A referral is credited to a referrer | | `referral.skipped` | A referral is rejected by the fraud guards | **Rewards**: the promise and its fulfillment. | Event | Fires when | | ------------------ | -------------------------------------------- | | `reward.granted` | Someone reaches a rung on your reward ladder | | `reward.fulfilled` | A reward is marked as actually given | | `reward.voided` | A reward is canceled and is no longer owed | **Forms**: captures beyond the signup itself. | Event | Fires when | | ------------------ | --------------------------------- | | `form.submitted` | Any form receives a submission | | `survey.completed` | A contact completes a survey form | | `task.completed` | A contact claims a points task | **Email**: per-recipient delivery and consent outcomes. | Event | Fires when | | -------------------- | ------------------------------------------- | | `email.sent` | One email is handed to the sending platform | | `email.bounced` | An email hard- or soft-bounced | | `email.complained` | A recipient marked an email as spam | | `email.unsubscribed` | A contact opts out of email | | `email.resubscribed` | A contact opts back in | **Engage**: broadcasts, automations and the public feed. | Event | Fires when | | ------------------ | ------------------------------------------------------- | | `campaign.sent` | A broadcast finished sending to its whole recipient set | | `automation.goal` | A contact reached an automation goal node | | `update.published` | A build-in-public update went live | > [!WARNING] `email.sent` fires once per recipient > A single broadcast to a large audience produces one delivery per contact. It is subscribable > because a CRM sync genuinely wants it, but do not turn it on unless your endpoint is ready for the > volume. ## What a payload looks like The envelope is Stripe-shaped, so if you have written a Stripe receiver you already know where to look. The event type is `type`, and the subject is nested at `data.object`. ```json { "id": "evt_01k2g6j4qx7c8vg2x5s6rb7tmf", "object": "event", "type": "signup.created", "api_version": "2026-10-27", "created": 1754039051, "created_at": "2026-08-01T09:04:11+00:00", "workspace_id": "ws_01k2g6j4qx7c8vg2x5s6rb7tp1", "project_id": "prj_7qk2mn4pr8vt", "data": { "object": { "object": "contact", "id": "con_4mkp9wq2rxhz", "created_at": "2026-08-01T09:14:11+00:00", "identifier": "ada@lovelace.dev", "identifier_type": "email", "email": "ada@lovelace.dev", "name": "Ada", "status": "subscribed", "stage": "waitlist", "verified_at": null, "consent": "signup", "consented_at": "2026-08-01T09:14:11+00:00", "unsubscribed_by": null, "unsubscribed_at": null, "referral_code": "AB12CD34", "referral_count": 0, "referred_by": null, "attributes": { "name": "Ada", "company": "Analytical Engines" }, "is_test": false, "referral_link": "https://acme.launchlist.page/?ref=AB12CD34", "position": 42 }, "properties": { "submission_id": "sub_01k2g6j4qx7c8vg2x5s6rb7vsd", "is_new": true } } } ``` `status` is the contact's subscription: `subscribed` (everyone who joins), `pending` (an imported contact you added as not subscribed), `unsubscribed`, `bounced` or `blocked`. Whether you see `subscribed` or `verified` for a subscribed contact depends on the `api_version` your endpoint was created on. Endpoints created on or after 2026-10-27 say `subscribed`. Endpoints created before that keep saying `verified`, as they always did, so nothing you built breaks. Every other value reads the same on both. Every delivery carries its version as `api_version`, and a new endpoint is made on the latest one. `attributes` is every non-identity answer the form collected, which is where your own custom fields arrive. `name` is lifted out of it for convenience rather than being a separate column. `created` is a Unix timestamp and `created_at` is the same moment in ISO 8601, so use whichever your code parses. `referral_link` is the link this contact shares to refer friends, and `position` is their place in line, including any head start you set. `position` is `null` when the contact has no place in line. `data.properties` carries the event's own annotations. It sits beside the object rather than inside it so a property can never shadow a field of the subject. A few worth knowing: - **`signup.created`** carries `is_new`. It is `false` when someone already on your list signs up again, so you can act on first signups only. - **`contact.stage_changed`** carries `from` and `to`, the keys of the stages the contact moved between, and `from_label` and `to_label`, the names you gave those stages. - **`referral.made`** carries `referrer`: the `id`, `name`, `email`, `referral_count` and `referral_link` of the person who shared their link. On the four `invite.*` events, `data.object` is the invitation: its `code`, `kind` (`personal`, `shared` or `open`), `status`, `expires_at`, seat counts, the `contact_id` it belongs to (for a shared or open code, the person who last used it), and from the latest redemption, `redeemed_at`, `channel` and your `external_id`. A few things to know before you build against this: - **The trust verdict is not on the contact object.** It is published only on `trust.changed`, whose `data.properties` carry the `from` and `to` tiers of the change. For a contact's current verdict at any other moment, read [`GET /v1/contacts/{id}`](/help/docs/api/contacts-retrieve), which returns `trust_tier`. - **Nothing is sent about a contact hidden past your plan's size.** An event about that contact is not delivered at all: not their signup, not a form response from them, not an `invite.*` event for them, and not a `referral.made` where they are the referrer. It does not appear in the events feed, `GET /v1/events`, either. These events are not queued for later: once you move up a size, new events about that contact are sent, and the ones from while they were hidden are not. A delivery already waiting when a contact becomes hidden is not sent, and shows as **Not sent** in the delivery log. Nothing is deleted. - **`is_test` is `true` for a form response sent in test mode.** Every id is a handle with a prefix that names the record: `evt_` for an event, `con_` for a contact, `prj_` for a project, `frm_` for a form, `sub_` for a submission, `inv_` for an invitation, `ws_` for your workspace. A record has the same handle here as in the [REST API](/help/docs/api), the events feed, Zapier and the MCP server, so you can pass one from a webhook straight to an API call. Treat a handle as an opaque string and do not parse it. Every delivery also carries ids as headers, so you can route and dedupe without parsing the body at all: `LaunchList-Event-Id` (the event, stable across retries and resends, so this is what you dedupe on), `LaunchList-Event-Type`, `LaunchList-Delivery-Id` (this endpoint's copy of the event, so two endpoints on one event get two of these), `LaunchList-Delivery-Attempt` (starting at 1; anything higher is a retry) and a `LaunchList-Webhooks/1` user agent. ## Verifying the signature **Do this before you trust a payload.** Anyone can POST to your URL; the signature is what proves we sent it. Every request carries: ```text LaunchList-Signature: t=1754039051,v1=5f8d2c... ``` Read `t` and every `v1` out of the header, recompute the HMAC-SHA256 of `"{timestamp}.{raw body}"` with your endpoint secret, and compare. ```php function verify(string $rawBody, string $header, string $secret): bool { $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') { $timestamp = ctype_digit($value) ? (int) $value : null; } elseif ($key === 'v1') { $signatures[] = $value; } } // Outside the tolerance the message is a replay, however good the signature is. if ($timestamp === null || abs(time() - $timestamp) > 300) { return false; } $expected = hash_hmac( 'sha256', $timestamp.'.'.$rawBody, str_replace('whsec_', '', $secret), ); // A rotation signs with two secrets, so accept ANY v1 that matches yours. foreach ($signatures as $candidate) { if (hash_equals($expected, $candidate)) { return true; } } return false; } ``` > [!WARNING] Strip the `whsec_` prefix before you hash > The prefix exists so the secret is easy to spot in your vault, and it is not part of the key. We > strip it before signing, so a receiver that hashes the prefixed string rejects every genuine > delivery. This is the most common reason a first webhook never verifies. > [!WARNING] Sign the timestamp too, not just the body > A signature over the body alone is valid forever, so anyone who captures one request can replay > it indefinitely. The timestamp is inside the signed string for exactly this reason. Reject > anything signed more than five minutes ago. The format is Stripe's, deliberately: every language already has copy-paste verification code for it. The same recipe, in PHP, JavaScript and Python, is on the webhook endpoint's own screen in the dashboard, so you can copy it there rather than retyping it from here. ## Rotating a secret without downtime Press **Rotate** under **Signing secret** on the endpoint's **Signing and verification** tab (you confirm your password). That issues a new secret, and the old one keeps signing for 24 hours, so both validate at once. Deploy your new secret, confirm deliveries still verify, then let the old one expire. Multiple `v1=` values in one header are legal, which is what makes this a non-event rather than an outage. ## Retries and failures Any `2xx` counts as delivered. We never follow redirects, so a `3xx` is a failure. You have 10 seconds to respond (5 to connect). A failed delivery is retried with growing gaps, up to 8 attempts over about 45 hours. After 50 failures in a row we pause the endpoint and email you, so one broken URL cannot back up delivery for everything else. Order is not guaranteed: a retry can arrive after a newer event. Every attempt is in the endpoint's **Deliveries** list with its request, response, status and timing, and you can press **Resend this event** on any delivery once you have fixed your side. ![An endpoint's Deliveries list, with one delivery marked Failed after 8 attempts and the rest marked Succeeded.](https://app.getlaunchlist.com/images/docs/plugins/webhook/05-delivery-log.webp "Every attempt is in the Deliveries list.") > [!NOTE] Respond fast, work later > Return `2xx` as soon as you have stored the payload, then process it asynchronously. Doing real > work before responding is what causes timeouts, which causes retries, which causes duplicates. ## Handling duplicates Retries mean your endpoint will occasionally see the same event twice. Every payload carries a unique event id, so record the ones you have handled and skip repeats. Assume at-least-once delivery, never exactly-once. ## Using the classic LaunchList dashboard Webhooks made on the **Plugins** page of the classic LaunchList dashboard send a `new_user` event, in that dashboard's own payload, for its waitlists. They keep working for now and are separate from the endpoints on this page, which send the events and the payload described above and sign every request. To move a waitlist here, [import it into a project](/help/docs/getting-started/import-from-v1), add an endpoint under **Embed & API → Developers → Webhooks**, and update your receiver to read the payload above and verify the signature. ## Next - [Slack notifications](/help/docs/plugins/slack): the official app for signups, milestones and digests, and how to post other events - [The REST API](/help/docs/api/contacts-create): for reading and writing rather than reacting ## Common questions ### How do I get notified when someone joins my waitlist? Add a webhook endpoint and subscribe to signup.created. LaunchList sends a signed HTTP POST to your URL within seconds of the signup, with the full contact in the body. ### What happens if my webhook endpoint is down? Deliveries retry with exponential backoff, and a circuit breaker pauses an endpoint that keeps failing so one broken URL cannot back up everything else. Every attempt is logged. ### How do I verify a LaunchList webhook is genuine? Every request carries a LaunchList-Signature header, an HMAC-SHA256 of the timestamp and the raw body keyed on your endpoint secret with its whsec_ prefix stripped. Recompute it and compare before trusting the payload.