You render the form. We take the signup.
Waitlist is API-only — there is no hosted page and no embed script. Two anonymous endpoints: read the waitlist, post an entry.
1
Read the waitlist (optional)
Fetch copy and branding at render time so marketing can change the headline without a redeploy. Anonymous — no token, no CORS credentials.
curl 'https://api.waitlist.productcraft.co/v1/waitlists/acme/acme-pro?lang=pt-BR'{
"slug": "acme-pro",
"workspace_slug": "acme",
"canonical_workspace_slug": "acme",
"display_name": "Acme Pro early access",
"description": "Sign up to be among the first to try Acme Pro.",
"status": "active",
"custom_fields": [],
"confirmation_message": "You're on the list. We'll be in touch.",
"recaptcha_site_key": null,
"referral_rewards": { "milestones": [] },
"variant": null,
"brand": {
"brand_name": "Acme",
"brand_logo_url": "https://cdn.acme.com/logo.png",
"primary_color": "#1E40AF"
}
}404 means “not found or not active” — a draft, paused or closed waitlist looks identical to a missing one from the outside.
?lang= (BCP-47) beats the Accept-Language header when picking which variant's copy to serve. When a variant is active, variant carries { id, locale, kind } — echo that id back as variant_id on submit so the entry is attributed. See chapter 5.
canonical_workspace_slug is there so a link built against a renamed workspace slug can 301 itself: compare it to the slug in your URL.
2
Submit an entry
curl https://api.waitlist.productcraft.co/v1/waitlists/acme/acme-pro/entries \
-H 'content-type: application/json' \
-d '{
"email": "ada@example.com",
"name": "Ada Lovelace",
"interest": "The realtime collaboration bits",
"referrer": "Twitter",
"referral_code": "C67AB5HS",
"metadata": { "company": "Acme Industries", "role": "engineer" }
}'email and name are both required. Everything else is optional: interest (free text), referrer (how they heard about you), referral_code (another entry's code — credits that entry), variant_id, recaptcha_token, and metadata — a free-form object for whatever else your form collects. There is no server-side field schema to register first; put your extra inputs in metadata and they come back on the entry.
Response:
{
"id": "150489b4-558d-4c2a-93b2-dec1b657558c",
"email": "ada@example.com",
"name": "Ada Lovelace",
"position": 147,
"status": "approved",
"referral_code": "C67AB5HS",
"created_at": "2026-07-28T20:09:16.626Z",
"effective_position": 147
}referral_code is this signup's code to share. Waitlist doesn't host a page, so build the share link yourself against your own form URL — https://acme.com/join?ref=C67AB5HS — and pass the value straight back as referral_code on the next submission.
status is approved on a waitlist that doesn't require manual review, otherwise pending. position is the immutable join order — it is never renumbered, including when earlier entries are deleted. Render position; effective_position is a forward-compatible mirror of it.
3
Errors to render
400— validation failed (missingname, malformed email), a disposable-email domain, or a rejected reCAPTCHA token. The body'smessageis an array of per-field messages you can map onto inputs.403 ORIGIN_NOT_ALLOWED— the page that sent the request is not on the waitlist's allowed origins (section 4). The body names theoriginit saw.404— waitlist not found or not active.409 This email is already on this waitlist— the friendliest handling is “you're already in”, since positions are never disclosed anonymously for an existing email.422 Waitlist is not accepting entries— the list is a draft, paused, closed or full. A new waitlist is a draft unless you create it with"status": "active"; every waitlist response says so inaccepting_signupsandsignups_note.429— per-IP, per-waitlist rate limit. The message carries the retry window in seconds.
4
Allow only your own sites (optional)
By default any site can post to your waitlist. To accept sign-ups only from your own pages, give the waitlist a list of origins — scheme and host, no path. https://*.acme.com covers every subdomain (handy for preview deployments). An empty list means any site again.
curl -X PATCH https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/waitlists/<waitlist_id> \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "allowed_origins": ["https://acme.com", "https://*.acme.com"] }'A browser on any other site gets 403 on the CORS preflight and on the post itself, so the form fails loudly instead of silently. Requests from your own server carry no Origin header and are not checked. You can also set allowed_origins when you create the waitlist.
5
Stop bots with reCAPTCHA (optional)
Create a reCAPTCHA v3 key in Google's reCAPTCHA admin console and save both keys it shows you on the waitlist: in the console under the waitlist's Settings, or through the API. The secret key is stored encrypted and never returned; the waitlist only reports recaptcha_secret_configured: true. Send "recaptcha_site_key": null to turn it off again.
curl -X PATCH https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/waitlists/<waitlist_id> \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "settings": { "recaptcha_site_key": "6Lc...", "recaptcha_secret_key": "6Lc..." } }'From then on every sign-up needs a token. Load reCAPTCHA with your site key, create a token with the action waitlist_signup just before you post, and send it as recaptcha_token. A missing or failing token gets 400 reCAPTCHA verification failed.
// <script src="https://www.google.com/recaptcha/api.js?render=6Lc..."></script>
grecaptcha.ready(async () => {
const token = await grecaptcha.execute('6Lc...', { action: 'waitlist_signup' });
await fetch('https://api.waitlist.productcraft.co/v1/waitlists/acme/acme-pro/entries', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, name, recaptcha_token: token }),
});
});The public read in section 1 returns recaptcha_site_key, so your form can load reCAPTCHA only when it is on. The ready-made snippets in the console's “Get the snippet” send the token for you once reCAPTCHA is on: copy them again after you save the keys.
6
Test it before you launch
Add a test sign-up to see the entry, the welcome email and your webhooks work — even while the waitlist is still a draft.
curl -X POST https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/waitlists/<waitlist_id>/test-entries \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "trigger_webhooks": true, "send_welcome_email": true }'The entry gets a test address at Amazon's mailbox simulator, so no real inbox is emailed, and is_test: true. It shows in your entries list but never takes a place in the queue (its position is negative) and is left out of counts, analytics and exports unless you pass include_test=true. Webhooks and the welcome email only go out when you ask, as above. Delete it like any other entry.
7
Leaderboard
Anonymous top-referrer ranking — the “skip the line” mechanic from chapter 5. Same access rule as the public info endpoint (active waitlists only).
curl 'https://api.waitlist.productcraft.co/v1/waitlists/acme/acme-pro/leaderboard?limit=10'[
{ "position": 1, "referral_count": 42, "masked_email": "a***@example.com" },
{ "position": 2, "referral_count": 31, "masked_email": "b***@acme.org" }
]A bare array, not an envelope. Emails are masked and only entries with at least one referral appear. limit is 1–50, default 50. Unmasked referrer data is admin-only, on GET .../analytics.