The Waitlist mental model.
Waitlist, entry, custom fields, and the two API surfaces they sit behind. Everything is scoped to a workspace.
1
Waitlist
A waitlist is one launch funnel — “Sign up to be notified when Acme Pro launches”. Per-workspace. You can run several in parallel (e.g. acme-pro for the main product, acme-beta for an early access). Each gets its own URL slug, fields, leaderboard, capacity, branding.
Status states:
draft— where every new waitlist starts. Invisible to the public lane until you activate it.active— accepting new entries.paused— temporarily off; resumable.closed— terminal. Existing entries stay and you keep reading + approving them via the admin lane, but the waitlist can never be reopened.
Legal transitions are draft → active | closed, active → paused | closed, paused → active | closed. Anything else returns 422.
In any state other than active, the public info and leaderboard endpoints return 404 (a draft waitlist is indistinguishable from a missing one to anonymous probes) and the public submit endpoint returns 422 Waitlist is not accepting entries.
2
Entry
One row per signup. Captures email and name (both required), plus a metadata object for whatever else your form collects. Every entry gets an immutable position and a unique referral_code. Its status moves through the approval workflow:
pending— signed up on a waitlist that requires manual approval. Waiting on your decision.approved— either auto-approved at signup (the default) or marked in by an operator. Fires theentry.approvedwebhook and the approval email.rejected— declined. Firesentry.rejectedand the rejection email.
There is no third outcome: PATCH .../entries/:entryId only accepts approved or rejected. Transitions are pending → approved | rejected and a reversal between the two; re-setting the current status is an idempotent no-op.
3
Custom fields
Beyond email and name, anything else you collect — company, role, “what would you use this for?” — rides along in the entry's metadata object on POST .../entries. It is stored verbatim and comes back on the admin entry payload and the CSV export.
Waitlist does not validate metadata against a schema, and never rejects an entry for a missing custom field: you render the form, so your form owns which fields exist and which are required. The waitlist can carry a declarative field list under settings.custom_fields (each item a { key, label, type, required, options } object) for renderers that want to read their form definition from the API, but it is advisory only.
4
The two API surfaces
Waitlist has a public side and an admin side:
- Public:
GET /v1/waitlists/:workspaceSlug/:waitlistSlug(info),GET .../leaderboard(top 50, emails masked), andPOST .../entries(signup). Keyed by slugs, no auth header — this is what your own frontend hits directly. Guarded by a per-IP rate limit, a disposable-email blocklist, and optional reCAPTCHA. - Admin:
/v1/workspaces/:workspaceId/...— everything else: creating waitlists, listing and approving entries, CSV export, variants, analytics, webhooks. Keyed by the workspace UUID, authenticated with the session cookie or aBearerpersonal access key. Unauthenticated calls get401.
5
Where Waitlist fits with Auth
Waitlist captures the signup. Auth manages the resulting user. The handoff is an explicit call — POST /v1/workspaces/:workspaceId/waitlists/:waitlistId/entries/:entryId/invite-to-app, or action: "invite-to-app" on the bulk endpoint. It creates the invite in your Auth app and emails the entry. Approving an entry does not invite it on its own.
The target app comes from the workspace's Waitlist settings (default_heimdall_app_slug) or an explicit app_slug in the body. With neither, the call returns 422.
If you want a fancier workflow (notify Slack, push to a CRM, gate by capacity), use the webhook instead. Chapter 6 covers it.