Waitlist guides
05 · Variants + referrals

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

POST /v1/workspaces/:workspace_id/waitlists/:waitlist_id/variants
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
  }'
201 Created
{
  "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.

  1. Locale candidates come from ?lang= first, then Accept-Language. Each tag also contributes its primary tag, so pt-BR can match a pt variant. en is always the last fallback.
  2. Inside the winning locale, ab rows win over locale rows. Multiple ab rows are picked by weighted random.
  3. No active variant matches? The waitlist's own copy is returned and variant is null.
GET /v1/waitlists/:workspace_slug/:waitlist_slug?lang=pt-BR
{
  "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.

POST /v1/waitlists/:workspace_slug/:waitlist_slug/entries
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.

Signup that credits a referrer
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"
  }'
201 Created
{
  "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_boost is a per-waitlist setting and defaults to 5.

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.

GET /v1/waitlists/:workspace_slug/:waitlist_slug/leaderboard
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 domains400 “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 429 with 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.)

Top referrers → bulk approve
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.