One waitlist, many copies. Credit the people who bring friends.
Variants swap the copy your public endpoint returns — per locale or per A/B cohort. Referrals credit an existing entry when a new signup uses its code.
1
What a variant is
A variant is a copy override attached to one waitlist. Same entries, same custom fields, same owner — only the strings that GET /v1/waitlists/:workspace_slug/:waitlist_slug returns change. Two kinds:
locale— translations. One row per language.ab— competing copy inside one language. Picked by weighted random.
Every variant carries a locale (BCP-47, stored lower-cased). The resolver picks the locale first, then the variant inside it — so an ab test and a set of translations coexist without fighting.
2
Create a variant
curl https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/waitlists/<waitlist_id>/variants \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"kind": "ab",
"locale": "en",
"copy": {
"display_name": "Built by founders, for founders.",
"description": "Early access for teams shipping their first product.",
"confirmation_message": "You are in. We will be in touch."
},
"weight": 50
}'{
"id": "76ecdc66-ef21-4f11-bd31-5633737b07d1",
"waitlist_id": "33a8b309-8753-4f08-90d7-9dc484eff392",
"kind": "ab",
"locale": "en",
"copy": { "display_name": "Built by founders, for founders." },
"weight": 50,
"active": true,
"created_at": "2026-07-28T20:16:46.931Z"
}kind, locale and copy are required. copy understands three keys — display_name, description, confirmation_message — and drops anything else. weight is 1–100 (default 1) and only matters inside an ab pool. active defaults to true; flip it to false with PATCH .../variants/:id to park a variant without deleting it.
3
How a variant gets picked
Resolution happens on the public read, once per request. There is no ?v= override and no sticky cookie — if you need stickiness, cache the returned variant id client-side.
- Locale candidates come from
?lang=first, thenAccept-Language. Each tag also contributes its primary tag, sopt-BRcan match aptvariant.enis always the last fallback. - Inside the winning locale,
abrows win overlocalerows. Multipleabrows are picked by weighted random. - No active variant matches? The waitlist's own copy is returned and
variantisnull.
{
"slug": "acme-pro",
"display_name": "Acme Pro",
"description": "Acesso antecipado.",
"status": "active",
"custom_fields": [],
"confirmation_message": "You're on the list. We'll be in touch.",
"variant": { "id": "0b943ea8-...", "locale": "pt-br", "kind": "ab" }
}4
Attributing signups to a variant
The public read tells you which variant was served. Round-trip its id on submit and it is stored on the 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",
"variant_id": "0b943ea8-06e4-41f8-8246-bde94b2b9f3e"
}'The id lands in metadata.variant_id on the entry, so per-variant conversion is a group-by over the entries you already have — read it from GET .../entries or the CSV export. There is no impressions counter and no per-variant conversion endpoint.
5
Referrals
Every entry is issued a short unique referral_code on creation. Put it in whatever share link you render, read it back on your own form, and pass it as referral_code on the public POST.
curl https://api.waitlist.productcraft.co/v1/waitlists/acme/acme-pro/entries \
-H 'content-type: application/json' \
-d '{
"email": "newperson@example.com",
"name": "New Person",
"referral_code": "DYPG2KK3"
}'{
"id": "6f42c8c9-906e-4ec1-95c9-923f1dd37f02",
"email": "newperson@example.com",
"name": "New Person",
"position": 3,
"status": "approved",
"referral_code": "NS71SBVG",
"effective_position": 3,
"created_at": "2026-07-28T20:17:00.978Z"
}The response is about the new entry — it carries its own fresh referral_code, not the referrer's. Server-side, the referrer's referral_count is incremented and the new entry's referred_by_entry_id is set. An unknown code is ignored: the signup still succeeds, nothing is credited.
Position never moves. position is the immutable signup order. The reward you show people is effective_position:
max(1, position − referral_count × referral_boost)referral_boostis a per-waitlist setting and defaults to5.
It appears on the public signup response and on every row of analytics.top_referrals.
6
Public leaderboard
Anonymous read, top 50, emails masked. Only entries with at least one referral appear.
curl https://api.waitlist.productcraft.co/v1/waitlists/acme/acme-pro/leaderboard[
{ "position": 5, "referral_count": 2, "masked_email": "d***@example.com" },
{ "position": 1, "referral_count": 1, "masked_email": "a***@example.com" }
]The waitlist has to be active; anything else 404s, same as the public info endpoint. Full PII (id, raw email, referral code) is admin-only and lives on analytics.top_referrals.
7
What the public endpoint rejects
Referral loops attract junk. These guards are always on — there is nothing to configure.
- Disposable domains —
400“Disposable email addresses are not accepted”. The signup is rejected outright, not flagged. - Duplicate email on the same waitlist —
409. Codes cannot be farmed by re-signing up. - Rate limit — 10 submissions per minute per IP per waitlist, then
429with the retry window in the message.
8
Invite the top referrers first
analytics.top_referrals is already ordered by referral count and carries entry ids — feed it straight into the bulk endpoint. (The entries list has no sort parameter; don't reach for one.)
WS=<workspace_id>; WL=<waitlist_id>
BASE=https://api.waitlist.productcraft.co/v1/workspaces/$WS/waitlists/$WL
curl -s "$BASE/analytics" -H "authorization: Bearer $PCFT_KEY" \
| jq '{ ids: [.top_referrals[] | select(.referral_count > 0) | .id],
action: "approve" }' \
| curl -s "$BASE/entries/bulk" \
-H "authorization: Bearer $PCFT_KEY" \
-H 'content-type: application/json' -d @-Bulk takes ids plus one action (approve / reject / invite-to-app), capped at 100 ids — see chapter 4.