Waitlist guides
06 · Webhooks + analytics

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 to approved.
  • entry.rejected — the entry moved to rejected.

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

POST /v1/workspaces/:workspace_id/webhooks
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"]
  }'
201 Created
{
  "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

Request headers
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...
Body — identical shape for all three events
{
  "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}`.

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

Fire a synthetic event, then read the log
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

GET /v1/workspaces/:workspace_id/waitlists/:waitlist_id/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. 0 when the waitlist is empty.
  • top_referrers groups the free-text referrer field (“how did you hear about us”), not referral codes. Entries that left it blank collapse into the null bucket.
  • top_referrals is the code-based leaderboard — full PII, ordered by referral_count, with the effective_position from 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.

GET /v1/workspaces/:workspace_id/waitlists/:waitlist_id/analytics/timeline
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

GET /v1/workspaces/:workspace_id/waitlists/:waitlist_id/entries/export.csv
curl -o entries.csv \
  https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/waitlists/<waitlist_id>/entries/export.csv \
  -H 'authorization: Bearer pcft_live_...'
entries.csv
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.346Z

Streams 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