Help and documentation

Create. Share. Connect.

Share your Unwaited page, embed the form, or build with the API.

On this page

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:

Readable address · your handle + form name
https://unwaited.com/w/saturday-studio/pottery-class

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

Permanent link · best for sharing and QR codes
https://unwaited.com/f/YOUR_UNIQUE_FORM_ID

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

Set capacity and opening hours

For Signups forms, set a total capacity or a daily, weekly, or monthly limit in the dashboard. Choose your timezone, reset time, and opening hours. Use Preorders for product stock and Bookings for dated sessions.

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.

HTML · auto-resizing embed
<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.

Choose a target or starting height

Add data-target="#signup" to render inside an existing element, or data-height="520" to set the initial height.

Place the tag in the page body. Tags placed in <head> render at the end of the body.

Website blocks scripts? Use an iframe

Use an iframe if your website blocks scripts. Set the height yourself.

HTML · fixed-height embed
<iframe
  src="https://unwaited.com/f/YOUR_UNIQUE_FORM_ID/embed"
  title="Signup form"
  width="100%"
  height="600"
  style="border:0;max-width:100%"
  loading="lazy"
></iframe>
React to a submission on your website

Listen on the website that contains the script embed:

JavaScript · Standard signup event
window.addEventListener("unwaited:submitted", (event) => {
  if (event.detail.status === "joined") {
    console.log("New signup:", event.detail.position);
  }
});

New embeds emit formId and status. Position and email are included only for a fresh join. A configured success redirect navigates the host page.

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

  1. Your customer fills in your interface. Send their answers to your own backend.
  2. Your backend calls Unwaited. Add your form's API key and POST the answers to the matching endpoint.
  3. Unwaited checks the submission. Required fields, published settings, available places or stock, and usage limits still apply with an API key.
  4. 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. Replace YOUR_UNIQUE_FORM_ID with 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.

MethodPathUse it to
GET/api/v1/forms/YOUR_UNIQUE_FORM_IDRead the published form, no API key needed
POST/api/v1/forms/YOUR_UNIQUE_FORM_ID/signupsSubmit to a Signups form (STANDARD)
POST/api/v1/forms/YOUR_UNIQUE_FORM_ID/ticketsJoin a Queue (QUEUE)
POST/api/v1/forms/YOUR_UNIQUE_FORM_ID/ordersPlace a Preorder (PREORDER)
POST/api/v1/forms/YOUR_UNIQUE_FORM_ID/bookingsReserve 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.

JavaScript · public form definition
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);
200 OK · example response (shortened)
{
  "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

  1. Publish a Signups form. Copy its ID from the customer link in Share.
  2. In your form’s Developers tab, find API key and choose Generate key. Save it as UNWAITED_API_KEY on your server.
  3. Replace YOUR_UNIQUE_FORM_ID below and run the request from your terminal or server.
Terminal · authenticated signup
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.

201 Created · example response
{
  "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.

Node.js · submit a signup
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.

POST /signups · example with custom fields
{
  "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

Queue request · name and email required
{
  "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.

Preorder request · replace product, pickup, and UUID
{
  "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.

Single-session booking request
{
  "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

422 · example validation error
{
  "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

StatusCodeMeaning
201joinedAdded. Body has entry.position.
200already_joinedAlready registered. No existing entry details or private status link are returned.
409waitlist_fullCapacity reached. For per-day/week/month capacity, error.reopensAt is the next reset.
409slot_fullThe 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.
423waitlist_pausedSignups are paused by the owner.
423waitlist_closedOutside the opening hours. error.reopensAt says when it opens.
422validation_failederror.fields[] lists each problem with fieldKey, code, message.
400captcha_requiredSend a Turnstile token as _turnstile or use an API key.
429rate_limitedBack off for Retry-After seconds.
404not_foundForm 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.created and entry.status_changed.
  • Preorders: order.created and order.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.

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

Node.js · webhook verification
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.

Read the usage and grace-period guide or compare plans.