Documentation
Embed a waitlist anywhere
Every waitlist gets a hosted page, a script tag, an iframe, and a JSON endpoint. Pick whichever fits your stack. Replace your-slug with the slug from your dashboard's Share tab.
Hosted page
Share the link directly, put it in a QR code, or link a button to it.
https://unwaited.com/w/your-slug
Script embed
Paste the tag exactly where the form should appear. It turns into an auto-sizing iframe in that spot; no wrapper div, no extra markup. Works in any HTML page, Webflow, Framer, WordPress, Carrd, or a React app via dangerouslySetInnerHTML. A tag that lands in <head> is moved to the end of the page instead of disappearing.
<script src="https://unwaited.com/embed/v1.js" data-unwaited="your-slug" async></script>
Optional attributes: data-target="#selector" to render into a specific element, data-height="520" for the initial height before the form reports its own.
Iframe
If you cannot run scripts, use a plain iframe. It will not auto-resize, so give it enough height.
<iframe src="https://unwaited.com/w/your-slug?embed=1" title="Waitlist" width="100%" height="480" style="border:0" loading="lazy"></iframe>
POST API
Send the form fields as JSON. Keys are the field keys from your builder; the built-in email field is always email.
POST https://unwaited.com/api/v1/waitlists/your-slug/entries
Content-Type: application/json
{ "email": "[email protected]", "name": "Jane" }Responses:
| Status | Code | Meaning |
|---|---|---|
| 201 | joined | Added. Body has entry.position. |
| 200 | already_joined | Email was already on the list; position is returned. |
| 409 | waitlist_full | Capacity reached. For per-day/week/month capacity, error.reopensAt is the next reset. |
| 409 | slot_full | The chosen slot option is full. error.field, error.value, and error.slots (spots left per option) are included. The form endpoint returns the same slots map. |
| 423 | waitlist_paused | Signups are paused by the owner. |
| 423 | waitlist_closed | Outside the opening hours. error.reopensAt says when it opens. |
| 422 | validation_failed | error.fields[] lists each problem with fieldKey, code, message. |
| 400 | captcha_required | Send a Turnstile token as _turnstile or use an API key. |
| 429 | rate_limited | Back off for Retry-After seconds. |
| 404 | not_found | Unknown slug. |
{
"ok": true,
"status": "joined",
"entry": {
"id": "…",
"position": 42,
"status": "WAITING",
"email": "[email protected]",
"statusUrl": "https://unwaited.com/w/your-slug/status/<token>",
"createdAt": "2026-01-31T10:00:00.000Z"
}
}statusUrl is the joiner's own page: their place in line, and a one-tap way to leave it. It is also what the confirmation email links to. Entries are WAITING, SERVED, or CANCELLED; only the first two hold a spot.
Values are coerced the way a browser form would send them: numbers may arrive as strings, checkboxes as "on", multi-selects as arrays or comma-separated strings. Fields hidden by your visibility rules are ignored.
Form definition
Build your own UI from the live schema and theme. Cached for 30 seconds.
GET https://unwaited.com/api/v1/waitlists/your-slug/form
{
"ok": true,
"waitlist": {
"slug": "your-slug", "name": "…", "status": "open" | "paused" | "full",
"schema": { "pages": [...], "fields": [...], "rules": [...], "submitLabel": "…", "success": {...} },
"theme": { "primary": "#111827", "radius": "md", ... },
"count": 128, "capacity": 500, "requireCaptcha": true, "turnstileSiteKey": "…", "poweredBy": true
}
}Captcha and API keys
Public submissions are protected with Cloudflare Turnstile. The hosted page and embeds handle it for you. From your own frontend, render the Turnstile widget with the turnstileSiteKey from the form endpoint and send its token as _turnstile.
Server-to-server calls skip the captcha with an API key from the Share tab:
curl -X POST https://unwaited.com/api/v1/waitlists/your-slug/entries \
-H "Content-Type: application/json" \
-H "Authorization: Bearer wl_live_…" \
-d '{"email":"[email protected]"}'You can also turn the captcha off per waitlist in Settings. A honeypot and rate limits stay on regardless.
Plain HTML forms
No JavaScript at all: post a regular form. With an Accept: text/html request the API redirects back to the hosted page showing the result.
<form action="https://unwaited.com/api/v1/waitlists/your-slug/entries" method="POST"> <input name="email" type="email" required /> <input name="name" type="text" /> <button type="submit">Join</button> </form>
Turn the captcha off for that waitlist or the post will be rejected, since a plain form cannot carry a Turnstile token.
Capacity windows and hours
Capacity can apply in total or per day, week, or month, resetting at a local time in the waitlist's time zone. With a periodic capacity, entry.position is the position within the current window ("#3 today") and entry.overallPosition is the all-time number.
A lead of N days makes signups count toward the window N days ahead: a bakery taking Monday's orders on Saturday sets a lead of 2, and the joiner sees "#12 for Monday". The opening hours still decide when the form accepts names.
Opening hours are a set of weekdays plus an open and a close time. A close time at or before the open time runs the window into the next day: open 6:00 PM, close 5:00 PM means registration opens the evening before and closes at five on the day. For a single event, set a one-off open and close instant instead. Outside any of these the list reports closed with reopensAt.
The form endpoint exposes status, reopensAt, timezone, and windowLabel so a custom UI can say the same thing the hosted page does.
Host page events
With the script embed, the host page hears about submissions:
window.addEventListener("unwaited:submitted", (e) => {
console.log(e.detail); // { slug, status: "joined" | "already_joined", position, email }
});If a redirect URL is set on the success screen, the loader navigates the host page there after a short delay.
Webhooks
On Starter and Pro, every signup is POSTed to your URL as JSON, and so is every status change (served, back to waiting, or the joiner leaving the line). The X-Waitlist-Event header names the event. Verify the signature before trusting the payload.
POST https://your-app.example/hooks/waitlist
X-Waitlist-Event: entry.created
X-Waitlist-Delivery-Id: dlv_…
X-Waitlist-Signature: t=1700000000,v1=<hex hmac-sha256(secret, t + "." + rawBody)>
{
"event": "entry.created",
"deliveryId": "…",
"waitlist": { "id": "…", "slug": "your-slug", "name": "…" },
"entry": { "id": "…", "position": 42, "status": "WAITING", "email": "[email protected]", "data": { "name": "Jane" }, "createdAt": "…" }
}
// Status changes use the same envelope:
X-Waitlist-Event: entry.status_changed
{
"event": "entry.status_changed",
"deliveryId": "…",
"waitlist": { "id": "…", "slug": "your-slug", "name": "…" },
"entry": { "id": "…", "position": 42, "status": "CANCELLED", "previousStatus": "WAITING", "actor": "joiner", "changedAt": "…", "email": "[email protected]" }
}import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody) {
const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Non-2xx responses are retried 6 times with exponential backoff (2, 4, 8, 16, 32, 64 minutes). Deliveries and manual redelivery are in the Webhooks tab.
Limits
- Request bodies up to 32 KB; JSON, form-encoded, or multipart without files.
- 10 submissions per minute per IP per waitlist, 120 per minute per waitlist.
- Free plan: unlimited waitlists, 100 signups a month across the account, "Powered by" badge. Over the cap the API answers 409
waitlist_fullwithreason: plan_limit. See pricing.