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.
A webhook is LaunchList telling your systems that something happened, the moment it happens. No polling, no delay.
Adding an endpoint#
- 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. - Under Events to send, choose the events it should receive. Subscribe to what you will act on, not everything.
- 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.


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.createdcarriesis_new. It isfalsewhen someone already on your list signs up again, so you can act on first signups only.contact.stage_changedcarriesfromandto, the keys of the stages the contact moved between, andfrom_labelandto_label, the names you gave those stages.referral.madecarriesreferrer: theid,name,email,referral_countandreferral_linkof 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, whosedata.propertiescarry thefromandtotiers of the change. For a contact's current verdict at any other moment, readGET /v1/contacts/{id}, which returnstrust_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 areferral.madewhere 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_testistruefor 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.

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.