# Signup API | Waitlyze Docs

> Accept signups from your server with a scoped API key.

Canonical: https://waitlyze.com/docs/api


Use this endpoint when you collect emails on your own backend and want Waitlyze to own verification, referrals, and storage. Browsers should use the [hosted page or embed](/docs/embed) instead - never expose an API key client-side.

See the [API Reference](/docs/api/reference) for the complete list of supported public endpoints and the boundary between public API keys and dashboard sessions.

## Create an API key first [#create-an-api-key-first]

Go to Settings → Integrations → API keys, choose a waitlist, name the key, and set expiry. Copy the secret before closing: only its hash is stored. See [API keys](/docs/api-keys).

<Callout type="warn" title="Keep keys on your server">
A key grants signup-write access to one waitlist. Store it in a server environment variable such as `WAITLYZE_API_KEY`. Never put it in a browser bundle or public repository.
</Callout>

## Create a signup [#create-a-signup]

`POST /api/waitlist` accepts JSON. Authentication uses the `apiKey` body field. This endpoint does **not** use a Bearer header.

If Turnstile is enabled for the waitlist in the form builder, include the visitor's `cf-turnstile-response` token. Waitlyze checks it with Cloudflare before creating the signup; missing, expired, replayed, or unverifiable tokens are rejected. Tokens are single-use. Browser forms should use the hosted page or embed so the verification widget is rendered for visitors.

<Tabs items={['Node.js', 'Python', 'cURL']}>
<Tab value="Node.js">
```js title="Server-side JavaScript"
const response = await fetch("https://waitlyze.com/api/waitlist", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    apiKey: process.env.WAITLYZE_API_KEY,
    waitlistId: "YOUR_WAITLIST_ID",
    email: "person@example.com",
    // referralCode: "OPTIONAL_REFERRAL_CODE"
  })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Signup failed");
```
</Tab>
<Tab value="Python">
```python title="Python · standard library"
import json
import os
from urllib.request import Request, urlopen
from urllib.error import HTTPError

request = Request(
    "https://waitlyze.com/api/waitlist",
    data=json.dumps({
        "apiKey": os.environ["WAITLYZE_API_KEY"],
        "waitlistId": "YOUR_WAITLIST_ID",
        "email": "person@example.com",
    }).encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="POST",
)
try:
    with urlopen(request, timeout=15) as response:
        result = json.load(response)
except HTTPError as error:
    result = json.load(error)
    raise RuntimeError(result.get("error", "Signup failed")) from error
```
</Tab>
<Tab value="cURL">
```bash title="Request example · placeholder key"
curl --request POST 'https://waitlyze.com/api/waitlist' \
  --header 'Content-Type: application/json' \
  --data '{
    "apiKey": "YOUR_SERVER_SIDE_API_KEY",
    "waitlistId": "YOUR_WAITLIST_ID",
    "email": "person@example.com"
  }'
```
</Tab>
</Tabs>

<TypeTable
  type={{
    apiKey: {
      type: "string",
      required: true,
      description: "Secret for the linked waitlist. Send it in the body, or in x-api-key / Authorization: Bearer headers (required for status lookups). See API keys.",
    },
    waitlistId: {
      type: "string",
      required: true,
      description: "The waitlist ID selected for the key. Must match the key's linked waitlist.",
    },
    email: {
      type: "string",
      required: true,
      description: "A valid email address, at most 320 characters.",
    },
    referralCode: {
      type: "string",
      required: false,
      description: "An existing referral code for the same waitlist. Also accepted as referral_code. See Referrals.",
    },
    "cf-turnstile-response": {
      type: "string",
      required: false,
      description: "Required when Turnstile is enabled for the waitlist. The single-use token returned by the visitor's Cloudflare Turnstile widget.",
    },
    marketingConsent: {
      type: "boolean",
      required: false,
      description: "Record marketing consent for this signup (drives consent-gated automations). Defaults to false.",
    },
  }}
/>

## Handle responses [#handle-responses]

```json title="Success response"
{
  "success": true,
  "message": "Successfully joined the waitlist!",
  "data": {
    "rank": 9,
    "position": 9,
    "display_position": 9,
    "points": 50,
    "referral_count": 0,
    "next_reward": { "referrals": 3, "reward": "Beta access", "remaining": 3 },
    "unlocked_rewards": [],
    "spots_to_advance": 2,
    "counts_hidden": false,
    "referral_code": "7f3a…",
    "referral_link": "https://waitlyze.com/w/your-slug?r=7f3a…",
    "verified": false
  }
}
```

`position` is the canonical queue position (`rank` is its legacy alias). `referral_code` is this signup's personal code and `referral_link` is the ready-to-share URL - render them in your confirmation UI when [referral sharing](/docs/referrals) is on. Both are `null`/`""` when sharing is disabled or the waitlist has no public slug. New signups start `verified: false` until they click the 24-hour confirmation link - except campaigns with verification turned off, which return `verified: true` immediately.

Rewards progress rides along in the same payload: `points` is the signup's balance (50 per verified signup, 30 per completed referral by default - founders can retune both per waitlist), `referral_count` counts verified referrals only, and `next_reward` is the nearest locked tier as `{ referrals, reward, remaining }` (`null` when everything is unlocked). `spots_to_advance` is the verified referrals needed to take the spot above (`0` at the top) - render it as “N more to pass #8”. `display_position` applies the founder's position inflation (`position` stays raw); `counts_hidden` is `true` for stealth launches, where your UI should hide every number and show the link alone.

Duplicate emails return `409` **with the existing position**, so a retry never loses state:

```json title="Duplicate response (409)"
{
  "success": false,
  "error": "Email already registered for this waitlist",
  "data": { "position": 9, "referral_code": "7f3a…", "referral_link": "https://…" }
}
```

| Status | Meaning | Fix |
| --- | --- | --- |
| `400` | Missing or invalid input | Validate email, IDs, and JSON shape. |
| `401` | Bad key, expiry, or wrong linked waitlist | Check [API keys](/docs/api-keys) and `waitlistId`. |
| `409` | Duplicate email, key reuse, or not accepting signups | Read `data.position` for duplicates; mint a new `Idempotency-Key` on reuse; distinguish state reasons. |
| `404` | Status lookup found no signup | The email never joined this waitlist (lookup only). |
| `429` | Rate limited | Wait for `Retry-After`; back off, do not loop. |
| `503` | Rate-limit service unavailable | Retry after the provided interval. |
| `500` | Temporary server failure | Retry with exponential backoff. |

New API signups follow the same [verification flow](/docs/signup-flow): they receive a 24-hour confirmation link and stay unverified until they click it.

<Callout title="Dashboard routes need a session, not a signup key">
`/api/v1/api-keys` and `/api/v1/waitlists` are browser-session routes. A `waitlist:write` signup key cannot manage keys, list subscribers, or access them.
</Callout>

## Retry safely with Idempotency-Key [#retry-safely-with-idempotency-key]

Network failures leave you unsure whether a signup landed. Send a client-generated `Idempotency-Key` header (1–128 chars: `A-Z a-z 0-9 _ -`) and retry with the **same** key and body:

```bash title="Idempotent signup"
curl --request POST 'https://waitlyze.com/api/waitlist' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: 01J9Z8QK7M5N3P2R4T6V8X0Z2C4' \
  --data '{ "apiKey": "YOUR_SERVER_SIDE_API_KEY", "waitlistId": "YOUR_WAITLIST_ID", "email": "person@example.com" }'
```

- Replays return the original signup with `"idempotentReplay": true` in `data` - no duplicate is created.
- Reusing a key with a **different** email returns `409` (`Idempotency-Key was already used…`). Mint a fresh key per distinct signup.
- The key is also accepted alongside `x-api-key` / Bearer auth, keeping secrets out of the JSON body if you prefer.

## Look up a signup's status [#look-up-a-signups-status]

`GET /api/waitlist?waitlistId=…&email=…` answers “where am I?” for custom frontends without consuming signup intake budget. The key travels in headers only - never in the query string:

```bash title="Status lookup"
curl 'https://waitlyze.com/api/waitlist?waitlistId=YOUR_WAITLIST_ID&email=person%40example.com' \
  --header 'X-Api-Key: YOUR_SERVER_SIDE_API_KEY'
```

```json title="Status response"
{ "success": true, "data": { "position": 9, "referral_code": "7f3a…", "referral_link": "https://…", "verified": true } }
```

Unknown emails return `404`. Responses are `private, no-store` - do not cache them in a CDN.

## Award custom points [#award-custom-points]

`POST /api/waitlist/points` credits off-site actions the join flow never sees - a survey completed, a Discord joined. Configure named events with default points under Rewards in the builder, then call with your server-side key:

```bash title="Custom point award"
curl --request POST 'https://waitlyze.com/api/waitlist/points' \
  --header 'Content-Type: application/json' \
  --header 'X-Api-Key: YOUR_SERVER_SIDE_API_KEY' \
  --data '{ "waitlistId": "YOUR_WAITLIST_ID", "email": "person@example.com", "event": "survey", "idempotencyKey": "srv-2026-001" }'
```

```json title="Award response"
{ "success": true, "data": { "awarded": true, "event": "survey", "label": "Survey completed", "points": 20, "balance": 100 } }
```

- `event` must be 1–40 chars (`a-z 0-9 -`). Unknown events return `400` unless you pass explicit `points` (0–100000) to override the configured default.
- The API key travels in the `X-Api-Key` or `Authorization` header only; keys in the request body are rejected with `400`.
- Only verified signups earn; unverified signups return `409` and farm-suspect signups return `awarded: false`. Retries with the same `idempotencyKey` return `awarded: false` without double-paying; omit it for once-per-signup-per-event semantics.

## Rate limits [#rate-limits]

Two budgets protect intake; both surface `Retry-After` on `429`/`503`:

- **Shared intake budget** - per campaign, client, and recipient windows (see [Troubleshooting](/docs/troubleshooting)).
- **Per-key daily budget** (5,000 signups + lookups per key per day). Successful `POST` signups and `GET` lookups (hits and `404`s) carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (Unix seconds); validation (`400`), auth (`401`), rate-limit (`429`/`503`), and server (`500`) errors - as well as `409` duplicate/key-reuse responses - do not. Graph remaining per key to spot a noisy integration before it caps.

## Next [#next]

<Cards>
<Card title="Custom frontend guide" href="/docs/custom-frontend" description="Pair the API with embeds, referrals, domains, and email end-to-end." />
<Card title="API keys" href="/docs/api-keys" description="Expiry, replacement, and revocation." />
<Card title="Signup and verification" href="/docs/signup-flow" description="What happens after 200 OK." />
<Card title="Troubleshooting" href="/docs/troubleshooting" description="Fix 401, 409, 429, and 503." />
</Cards>

## Related Links
- [API Reference](https://waitlyze.com/docs/api/reference) - REST API endpoints
- [Embed Widget](https://waitlyze.com/docs/embed) - Embed instructions
- [Waitlyze Home](https://waitlyze.com/) - Product overview
