Hear about new signups. Measure the funnel.
Webhooks push entry events into your stack. Analytics answers how many, how fast, and who is bringing friends.
1
The three events
entry.created— a new signup landed.entry.approved— the entry moved toapproved.entry.rejected— the entry moved torejected.
That is the whole list — anything else is rejected at subscribe time. On a waitlist that does not require approval, one signup fires both entry.created and entry.approved, so consumers do not have to special-case the auto-approve path. Make your handler idempotent on X-Waitlist-Event-Id.
2
Subscribe
curl https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/webhooks \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"url": "https://acme.example.com/webhooks/waitlist",
"events": ["entry.created", "entry.approved"]
}'{
"id": "9b7c2528-bed5-4585-aa90-6ccdd98639b5",
"workspace_id": "24fab978-7cd7-48ae-bce1-aba876b11957",
"url": "https://acme.example.com/webhooks/waitlist",
"events": ["entry.created", "entry.approved"],
"active": true,
"failure_count": 0,
"last_status_code": null,
"last_attempt_at": null,
"auto_disabled_at": null,
"created_at": "2026-07-28T20:17:19.442Z",
"secret_one_time_reveal": "bed7d115...ac645dca"
}secret_one_time_reveal is the signing secret in clear, returned once, only here. Store it immediately — no later read returns it. POST .../webhooks/:id/rotate-secret is the only way to get a new one, and it answers { "id": "...", "secret": "..." }.
Webhooks are workspace-scoped. There is no per-waitlist filter: a subscription receives events from every waitlist in the workspace, and each payload carries waitlist_id so you can route server-side.
3
What arrives
x-waitlist-event: entry.created
x-waitlist-event-id: 83cfc794-78e9-4102-96de-3a9a6f5bc31e
x-waitlist-delivery-attempt: 1
x-waitlist-signature: t=1785269874,v1=42d55266d69b14bc...{
"event": "entry.created",
"occurred_at": "2026-07-28T20:17:54.893Z",
"workspace_id": "24fab978-7cd7-48ae-bce1-aba876b11957",
"waitlist_id": "33a8b309-8753-4f08-90d7-9dc484eff392",
"entry": {
"id": "1d0b7828-0e1a-407b-8706-aeb7ff619088",
"email": "ada@example.com",
"name": "Ada Lovelace",
"position": 4,
"status": "approved",
"referral_code": "464QTHE0",
"referral_count": 0,
"referred_by_entry_id": null,
"created_at": "2026-07-28T20:17:54.886Z",
"updated_at": "2026-07-28T20:17:54.886Z"
}
}Custom field values (metadata), interest and referrer are not in the payload — fetch the entry by id if you need them.
4
Verify the signature
Same recipe as Auth and Mail webhooks — only the header name differs. HMAC-SHA256 over `${timestamp}.${rawBody}`.
import crypto from "node:crypto";
app.post("/webhooks/waitlist",
express.raw({ type: "application/json" }),
(req, res) => {
const m = /t=(\d+),v1=([0-9a-f]+)/i.exec(
req.header("x-waitlist-signature") ?? "",
);
if (!m) return res.sendStatus(400);
const [, ts, sig] = m;
// Reject anything older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.WAITLIST_WEBHOOK_SECRET!)
.update(`${ts}.${req.body.toString("utf8")}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.sendStatus(401);
}
res.sendStatus(200); // ack fast, process async
},
);The timestamp is inside the HMAC, so a captured signature cannot be replayed once your window closes. Rotation swaps the signing key immediately — the old secret stops verifying the moment rotate-secret returns, so be ready to deploy the new value straight away.
5
Test + inspect deliveries
Every attempt is recorded. Use it instead of grepping your own logs when a hook goes quiet.
curl -X POST https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/webhooks/<id>/test \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "event": "entry.created" }'
curl 'https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/webhooks/<id>/deliveries' \
-H 'authorization: Bearer pcft_live_...'{
"data": [
{
"id": "27f2e149-bd44-4188-91fa-245091362c00",
"webhook_id": "9b7c2528-bed5-4585-aa90-6ccdd98639b5",
"event_id": "83cfc794-78e9-4102-96de-3a9a6f5bc31e",
"event_type": "entry.created",
"attempt_number": 1,
"status_code": 200,
"response_body_truncated": "ok",
"error_message": null,
"latency_ms": 105,
"attempted_at": "2026-07-28T20:17:55.023Z"
}
]
}Newest first, most recent 50. The test fire is asynchronous — the call returns before the POST goes out, so poll /deliveries for the result.
Failures are not retried today — each event is delivered once and the outcome is recorded. A webhook is auto-disabled after 20 consecutive failures, or after failing continuously for 24 hours; auto_disabled_at and failure_count on the webhook tell you it happened. Re-enable with PATCH .../webhooks/:id and { "active": true }.
6
Analytics
curl https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/waitlists/<waitlist_id>/analytics \
-H 'authorization: Bearer pcft_live_...'{
"waitlist_id": "33a8b309-8753-4f08-90d7-9dc484eff392",
"total_entries": 7,
"by_status": { "pending": 0, "approved": 7, "rejected": 0 },
"signups_today": 7,
"signups_this_week": 7,
"signups_this_month": 7,
"referred_count": 3,
"viral_coefficient": 0.4285714285714286,
"top_referrers": [
{ "referrer": null, "count": 7 }
],
"top_referrals": [
{
"id": "1e1e0c99-26a2-4f07-982c-eb60d20305f6",
"email": "ada@example.com",
"name": "Ada Lovelace",
"referral_code": "SCDQ0TZB",
"referral_count": 2,
"position": 5,
"effective_position": 1
}
]
}viral_coefficient—referred_count / total_entries, the K-factor proxy.0when the waitlist is empty.top_referrersgroups the free-textreferrerfield (“how did you hear about us”), not referral codes. Entries that left it blank collapse into thenullbucket.top_referralsis the code-based leaderboard — full PII, ordered byreferral_count, with theeffective_positionfrom chapter 5.
There is no impressions or conversion metric: Waitlist never sees your landing page, only the submits.
7
Timeline
Daily signup counts, UTC, dense — every day in the window gets a row even when it is zero.
curl 'https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/waitlists/<waitlist_id>/analytics/timeline?since=2026-07-26&until=2026-07-29' \
-H 'authorization: Bearer pcft_live_...'{
"granularity": "day",
"points": [
{ "date": "2026-07-26", "count": 0 },
{ "date": "2026-07-27", "count": 0 },
{ "date": "2026-07-28", "count": 3 }
]
}since (inclusive) and until (exclusive) are both required ISO-8601 values — omitting either is a 400. The window must be forward-going and at most 366 days. Granularity is always daily; there is no bucket parameter.
8
CSV export
curl -o entries.csv \
https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/waitlists/<waitlist_id>/entries/export.csv \
-H 'authorization: Bearer pcft_live_...'id,email,name,position,status,interest,referrer,referral_code,referral_count,referred_by_entry_id,invited_at,invited_to_app_slug,created_at
7c2b32e2-...,ada@example.com,Ada Lovelace,1,approved,,,DYPG2KK3,1,,,,2026-07-28T20:16:51.346ZStreams every entry on the waitlist — the endpoint takes no filters, so slice it downstream. Custom field values are not columns; use GET .../entries and read metadata when you need them.
What's next
You've finished the series
- Waitlist API reference for every endpoint
- Auth quickstart if you're wiring up the invite-to-app handoff
- Mail deliverability for the notification emails Waitlist can send through Mail