Webhooks
Push waitlist events to your systems as they happen — CRM sync, Slack alerts, provisioning. Add an endpoint on the waitlist's Webhooks tab in the dashboard; deliveries start immediately.
Events
| Event | Fires when |
|---|---|
signup.created | A new entry joins the waitlist |
invite.sent | An invite email goes out |
invite.accepted | An invited entry redeems its invite |
entry.removed | An entry is removed from the queue |
Every delivery is a JSON POST with this envelope:
{
"id": "signup.created-8f4c1e02-…",
"event": "signup.created",
"createdAt": "2026-07-17T12:00:00.000Z",
"waitlistId": "wl_launch",
"data": {
"id": "8f4c1e02-…",
"waitlistId": "wl_launch",
"identifierKind": "email",
"email": "ada@acme.com",
"phone": "",
"name": "Ada Lovelace",
"company": "Acme",
"position": 41,
"referrals": 3,
"referralCode": "481920",
"status": "waiting",
"source": "widget",
"signedUpAt": "2026-07-16T09:30:00.000Z"
}
}An entry is identified by an email address, a phone number, or both. identifierKind says which, and email is an empty string on a phone-identified entry — read phone there instead.
Lucerna does not send SMS, so those entries receive no lifecycle emails. signup.created and invite.sent both carry the number, which is what you send from if you deliver your own messages.
On invite.sent and invite.accepted, data also carries inviteCode — the code from the invite link and the merge tag — so you can provision or reconcile against the same invite the email offered:
{
"event": "invite.sent",
"data": {
"email": "ada@acme.com",
"status": "invited",
"inviteCode": "K7Q2M9XF"
}
}id is unique per (event, entry) — use it as an idempotency key if your handler may see retries. Failed deliveries retry automatically with backoff; endpoints that keep failing are eventually disabled.
Verifying deliveries
Deliveries are signed (Standard Webhooks: HMAC-SHA256, svix-signature header). Verify the signature before acting on a payload — the secret is under the eye icon on the endpoint row.
Two rules, in every language: verify against the raw request body, and return a 2xx fast (anything else is retried).
JavaScript / TypeScript
Use @lucerna-dev/webhooks — zero dependencies, verification runs locally, and the same build works on Node 18+, Cloudflare Workers, Deno and Bun:
import { webhooks, WebhookVerificationError } from "@lucerna-dev/webhooks";
export async function handleWebhook(request: Request): Promise<Response> {
const payload = await request.text(); // the RAW body — never re-serialize
let event;
try {
event = await webhooks.verify({
payload,
headers: {
id: request.headers.get("svix-id") ?? "",
timestamp: request.headers.get("svix-timestamp") ?? "",
signature: request.headers.get("svix-signature") ?? "",
},
webhookSecret: process.env.LUCERNA_WEBHOOK_SECRET ?? "",
});
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response("invalid signature", { status: 400 });
}
throw error;
}
if (event.event === "signup.created") {
console.log(`${event.data.email} joined ${event.waitlistId}`);
}
return new Response(null, { status: 204 });
}verify also accepts a Fetch Headers instance or Node's req.headers object directly, rejects deliveries older than 5 minutes (replay protection), and never returns an unverified payload.
Other languages
Deliveries verify with any Standard Webhooks implementation — use the standardwebhooks library for your language (Python, Ruby, Go, PHP, Java, Rust, C#, …), or the svix library, which uses the same scheme:
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["LUCERNA_WEBHOOK_SECRET"])
event = wh.verify(raw_body, dict(request.headers)) # raises on failureIf your language has neither, the scheme is: base64-decode the secret after the whsec_ prefix, compute HMAC-SHA256 over `${svix-id}.${svix-timestamp}.${raw body}`, base64-encode it, and compare in constant time against the v1,… entries of the svix-signature header. Reject timestamps more than a few minutes from your clock.
REST
No library at all: POST the delivery to /sdk/v1/waitlist/webhooks/verify and act on it only when the response says "verified": true. Costs one extra network hop per delivery.