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.

Updated ·10 min read

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

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.

{
    "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}, 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, 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:

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.

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;
}

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.
Every attempt is in the Deliveries list.

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, add an endpoint under Embed & API → Developers → Webhooks, and update your receiver to read the payload above and verify the signature.

Next#

  • Slack notifications: the official app for signups, milestones and digests, and how to post other events
  • The REST API: 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.

Was this page helpful?