Help and documentation
Create. Share. Connect.
Share your Unwaited page, embed the form, or build with the API.
First, create your form. Choose Bookings, Preorders, Queues, or Signups, add your details, and publish. Then choose how customers will use it:
1. Unwaited page
Publish your form, open Share, and copy the customer link. Send it in a message, add it to your bio, or download a QR sign. Unwaited hosts the form and handles submissions. No website or code needed.
Your form has two links. Both open the same form:
https://unwaited.com/w/saturday-studio/pottery-classThis is the address customers see. Here, saturday-studiois your account handle and pottery-class is the form's URL name (slug). You can customize these names.
https://unwaited.com/f/YOUR_UNIQUE_FORM_IDShare copies this permanent link, and QR signs use it too. It opens your form at its current readable address, even after you rename your handle or form slug. Use it on printed materials and anywhere you want the link to keep working without updates.
2. Embed the form
In Share → Add to website, copy the script and paste it into your website's HTML embed block. It resizes automatically. Unwaited handles the form, validation, and CAPTCHA for you.
<script
src="https://unwaited.com/embed/v1.js"
data-form-id="YOUR_UNIQUE_FORM_ID"
async
></script>Your copied script already contains your form ID. The example above uses YOUR_UNIQUE_FORM_ID as a placeholder.
3. Using API
Use the API to read your published form and submit customer answers from your own backend. The examples below show the requests, responses, and error handling for each form type. Keep your secret API key on your server; authenticated submissions do not need a CAPTCHA token.
How the API works
- Your customer fills in your interface. Send their answers to your own backend.
- Your backend calls Unwaited. Add your form's API key and POST the answers to the matching endpoint.
- Unwaited checks the submission. Required fields, published settings, available places or stock, and usage limits still apply with an API key.
- Your backend reads the response. Show success, a private status link, or a useful error to the customer. Accepted records appear in your Unwaited dashboard, subject to your plan's usage rules.
GET reads the published form configuration. It does not return your customer list. These endpoints accept submissions; they are not an API for editing forms or managing your account. A preorder records an order, but does not charge the customer.
Authenticate with your form’s API key
- Form ID: the part after
/f/in the customer link in Share. ReplaceYOUR_UNIQUE_FORM_IDwith this value. It stays the same when you rename the form or its address. - API key: a secret for that specific form. Send it in
Authorization: Bearer YOUR_API_KEY, not in the URL or JSON body. - GET requests: the published definition is public and needs no key.
- POST requests: a valid key bypasses CAPTCHA and allowed-domain checks. It does not bypass validation, capacity, or rate limits.
The key is shown once when generated. Rotating or revoking it invalidates the old key immediately. Update your server environment after rotating it. Your dashboard login cookie is not an API key.
An absent or invalid key falls back to public submission rules, rather than always returning 401. If your server getscaptcha_required, check the Bearer header and that the key belongs to this form.
API endpoints
Every API route starts with https://unwaited.com/api/v1/forms/YOUR_UNIQUE_FORM_ID. Use the same ID as your embed, not your handle or editable form address.
| Method | Path | Use it to |
|---|---|---|
| GET | /api/v1/forms/YOUR_UNIQUE_FORM_ID | Read the published form, no API key needed |
| POST | /api/v1/forms/YOUR_UNIQUE_FORM_ID/signups | Submit to a Signups form (STANDARD) |
| POST | /api/v1/forms/YOUR_UNIQUE_FORM_ID/tickets | Join a Queue (QUEUE) |
| POST | /api/v1/forms/YOUR_UNIQUE_FORM_ID/orders | Place a Preorder (PREORDER) |
| POST | /api/v1/forms/YOUR_UNIQUE_FORM_ID/bookings | Reserve a Booking (CLASS) |
Match the POST endpoint to form.workflow in the definition response. Authenticate submissions from your server with Authorization: Bearer YOUR_API_KEY.
Read your form definition
No API key needed. Get the published fields, theme, workflow, and availability before building your own UI.
const response = await fetch(
"https://unwaited.com/api/v1/forms/YOUR_UNIQUE_FORM_ID"
);
const result = await response.json();
if (!response.ok) throw new Error(result.error.message);
const form = result.form;
console.log(form.workflow, form.schema.fields);{
"ok": true,
"form": {
"id": "YOUR_UNIQUE_FORM_ID",
"name": "Studio updates",
"workflow": "STANDARD",
"status": "open",
"publishedVersion": 1,
"requireCaptcha": false,
"turnstileSiteKey": null,
"schema": {
"fields": [
{ "key": "email", "type": "email", "label": "Email", "required": true }
]
}
}
}This is a Signups form with one required answer, email, and CAPTCHA disabled. Submit to /signups. The response also includes styling and availability fields, omitted above.
The CAPTCHA fields are used by hosted forms and embeds. Your server can omit CAPTCHA tokens when it submits with a valid API key.
Availability and caching
Use status, reopensAt, timezone, and windowLabel for availability. Slot fields include remaining places in slots.
Booking definitions use no-store. Other definitions may be cached for 30 seconds in browsers and 60 seconds in shared caches. Submission responses are authoritative.
Make your first request
- Publish a Signups form. Copy its ID from the customer link in Share.
- In your form’s Developers tab, find API key and choose Generate key. Save it as
UNWAITED_API_KEYon your server. - Replace
YOUR_UNIQUE_FORM_IDbelow and run the request from your terminal or server.
curl --request POST "https://unwaited.com/api/v1/forms/YOUR_UNIQUE_FORM_ID/signups" \
--header "Authorization: Bearer $UNWAITED_API_KEY" \
--header "Content-Type: application/json" \
--data '{"email":"[email protected]"}'This uses an email-only form. Add any other required answers using their field keys from the builder. Keep the API key out of browser JavaScript and HTML.
{
"ok": true,
"status": "joined",
"entry": {
"id": "example-entry-id",
"position": 42,
"status": "WAITING",
"email": "[email protected]",
"statusUrl": "https://unwaited.com/f/YOUR_UNIQUE_FORM_ID/status/example-private-token",
"createdAt": "2026-10-21T14:00:00.000Z"
}
}Show the customer their entry.statusUrl. It is private and returned only on a fresh signup. A repeat Standard signup returns 200 already_joined without entry details.
Submit from JavaScript on your server
Call this function from your backend after collecting the customer's answers. It returns both the HTTP status and JSON body so your application can handle success and errors explicitly.
async function submitSignup(answers) {
const apiKey = process.env.UNWAITED_API_KEY;
if (!apiKey) throw new Error("Set UNWAITED_API_KEY on your server");
const response = await fetch(
"https://unwaited.com/api/v1/forms/YOUR_UNIQUE_FORM_ID/signups",
{
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(answers)
}
);
return {
httpStatus: response.status,
retryAfter: response.headers.get("Retry-After"),
body: await response.json()
};
}
const result = await submitSignup({ email: "[email protected]" });
// 201: show result.body.entry.statusUrl to this customer.
// 200 already_joined: show "You are already signed up".
// Other statuses: inspect result.body.error.code and message.
// A network failure may mean the request was accepted; see retries below.Send custom answers
Read form.schema.fields from the definition. Use each field's key as the JSON property, not its label or internal ID. Include every required field that applies to the customer's answers.
{
"email": "[email protected]",
"name": "Jane Doe",
"interests": ["workshops", "new-releases"]
}This example assumes your published form has nameand interests fields with those option values. Signups and queues accept answers at the top level. Preorders put custom answers inside data; bookings useanswers. File fields require the upload flow, not a file URL or attachment in this JSON. Use the hosted form or embed if you need uploads without building that flow.
Answer formats and entry statuses
Numbers may be strings, checkboxes may be "on", and multi-selects may be arrays or comma-separated strings. Hidden fields are ignored.
WAITING and SERVED entries hold a place. CANCELLED entries free it.
Queues, preorders, and bookings
All paths below follow /api/v1/forms/YOUR_UNIQUE_FORM_ID. Send JSON with the same Bearer header as the signup example. The endpoint must match form.workflow; otherwise the API returns 409 wrong_workflow.
Queue: POST /tickets
{
"name": "Jane Doe",
"email": "[email protected]"
}Add required custom answers at the top level. If party size is enabled, include party_size. A fresh ticket returns201 joined with entry.code andentry.statusUrl. Show the customer the four-character code, not entry.position, which is internal queue order. An email already in the active line returns409 already_joined without its private link.
Preorder: POST /orders
Read form.preorder.products andform.preorder.pickupOptions from the definition. Replace the product and pickup placeholders below. Generate a fresh UUID for idempotencyKey for each intended order; the UUID shown is only an example.
{
"idempotencyKey": "f81d4fae-7dec-4a0e-a765-00a0c91e6bf6",
"name": "Jane Doe",
"email": "[email protected]",
"pickup": "PICKUP_OPTION_FROM_FORM",
"items": [{ "productId": "PRODUCT_ID_FROM_FORM", "quantity": 2 }],
"data": {}
}Put required custom answers in data. Success returns201 created with order.id,order.status, and order.statusUrl. An identical retry returns 200 already_submitted, without the private link. Changing the body while reusing its key is a conflict. Stock is checked when the order is submitted.
Booking: POST /bookings
Read form.classes and form.publishedVersionfirst. For a SINGLE model, choose an available ID from form.classes.sessions. Replace the version, session ID, and example UUID below.
{
"requestKey": "550e8400-e29b-41d4-a716-446655440000",
"__unwaited_publishedVersion": 1,
"name": "Jane Doe",
"email": "[email protected]",
"answers": {},
"selection": { "sessionId": "SESSION_ID_FROM_FORM" }
}Put required custom answers in answers. ForREGULAR or SHARED models, selection is{ "choiceId": "...", "startSessionId": "..." }using a choice's id and firstSessionIdfrom form.classes.choices.
Success returns 201 created with a top-levelstatusUrl. An identical retry returns200 already_booked without that link. Bookings require the current published version. On 409 page_updated, fetch the definition again and let the customer review their selection before resubmitting. Use the hosted form or embed if you want Unwaited to handle these calendar details.
Handle errors and retries
{
"ok": false,
"error": {
"code": "validation_failed",
"message": "Some fields are invalid",
"fields": [
{ "fieldKey": "email", "code": "invalid_email", "message": "Enter a valid email address" }
]
}
}Display field errors beside the matching inputs. For 429, wait the number of seconds in Retry-After.
- 422: correct the answers before sending again.
- 409: inspect the error code. The form may be full, the workflow may be wrong, or the published configuration may have changed.
- 423: admissions are paused or closed. Show the message instead of repeatedly submitting.
- Network timeout or 5xx: the result may be uncertain. Do not assume that no record was created.
For preorders, generate one UUID idempotencyKey per intended order. For bookings, use one UUID requestKey. Save the key and request body before sending. Reuse both unchanged when retrying; a fresh key represents a new request.
Signups return already_joined while the same normalized email has a WAITING entry in that form. A served or cancelled customer can join again. Queues reject an email already in the active line. Neither uses the order's idempotency key, so a retry after the customer's status changes can create a new record.
Private status links are returned on the fresh creation only. Save the successful response securely and show the link to that customer. A duplicate response will not recover a lost link.
Common signup response codes
| Status | Code | Meaning |
|---|---|---|
| 201 | joined | Added. Body has entry.position. |
| 200 | already_joined | Already registered. No existing entry details or private status link are 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 | Form not found or not publicly available. |
Receive updates with webhooks
On Starter or Pro, add your HTTPS endpoint in the Webhooks tab. Verify X-Waitlist-Signature against the raw request body before processing an event.
- Signups:
entry.createdandentry.status_changed. - Preorders:
order.createdandorder.status_changed.
Queue and Booking webhooks are not available. Held Standard entries send no webhook until released.
Example headers and payloads
Webhooks retain their existing waitlist envelope and X-Waitlist-* headers. Use waitlist.idas the form ID for the new API, not waitlist.slug.
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": "legacy-internal-key", "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": "legacy-internal-key", "name": "…" },
"entry": { "id": "…", "position": 42, "status": "CANCELLED", "email": "[email protected]", "data": {}, "createdAt": "…" },
"previousStatus": "WAITING",
"actor": "joiner",
"changedAt": "…"
}Verify a signature in Node.js
Pass the raw body before JSON parsing and the webhook secret from your dashboard. Reject invalid signatures and deduplicate by deliveryId.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody) {
if (typeof header !== "string") return false;
const { t, v1 } = Object.fromEntries(
header.split(",").map((part) => part.split("="))
);
const timestamp = Number(t);
if (!Number.isFinite(timestamp) || !/^[a-f0-9]{64}$/.test(v1 ?? "")) {
return false;
}
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest();
return timingSafeEqual(expected, Buffer.from(v1, "hex"));
}Delivery stops after 6 total attempts. Failed attempts are retried with exponential backoff (2, 4, 8, 16, then 32 minutes). Deliveries and manual redelivery are in the Webhooks tab.
Limits and usage
- Request size: Standard entries accept up to 32 KB of JSON, form-encoded data, or multipart data without files.
- Signup rate: 10 submissions per minute per IP per form; 120 per minute per form.
- Free allowance: 300 signups per UTC month across the account, with unlimited forms.
All form types share one lifetime 120-hour account grace period. Grace requests stay accessible and are never billed retroactively. After it ends, new uncovered Standard details are held from the owner; queues, preorders, and bookings pause new uncovered requests. Accepted customers keep their private status links.
Abuse ceiling
Standard entries stop at ten times the included monthly allowance (3,000 on Free). The API returns 409 waitlist_full with reason: abuse_ceiling.