API reference
Base URL https://api.enlist.dev. Every endpoint takes an Authorization bearer token. All bodies and responses are JSON.
Scopes
Every key carries a set of scopes, chosen when you create it under API keys. A key
missing the scope an endpoint asks for gets 403 forbidden — the account and the waitlist are
never revealed as the reason.
| Scope | Grants |
|---|---|
account:read | GET /v1/account |
waitlists:read | GET /v1/waitlists, GET /v1/waitlists/:id, its /stats and its /fields |
waitlists:write | POST /v1/waitlists, PATCH /v1/waitlists/:id, and every write under /fields |
waitlists:delete | DELETE /v1/waitlists/:id |
signups:read | GET /v1/waitlists/:id/signups and its /export |
signups:write | POST /v1/waitlists/:id/signups |
signups:delete | DELETE /v1/waitlists/:id/signups/:signupId |
Deleting is a scope of its own on both resources, separate from writing. The common key is a server handling a signup form: it posts constantly and deletes never, so it should not be able to erase anything.
Scopes are fixed when the key is minted
A key holds the scopes you picked, and only those. Selecting every box grants everything that exists at that moment — it does not pick up scopes added later. To grant a new one, create a new key.
GET /v1/account
The plan behind the key, and live quota usage. Unlimited plans report 0 for the limit and
remaining fields. See Pricing & limits for what each plan allows.
// 200
{
"account_id": "550e…",
"plan": "free",
"quota": {
"tier": "free",
"signups_used": 450,
"signups_limit": 500,
"signups_remaining": 50,
"percent_used": 90,
"can_add_signup": true
}
}POST /v1/waitlists
Create a waitlist. Names are unique per account.
// request
{ "name": "my-launch" }
// 201
{ "id": "8f1c…", "name": "my-launch", "created_at": "2026-08-04T10:00:00.000Z" }GET /v1/waitlists
Every waitlist on the account, with live signup counts.
// 200
{
"data": [{ "id": "8f1c…", "name": "my-launch", "created_at": "…", "signup_count": 1203 }],
"total": 1,
"limit": 1,
"offset": 0,
"has_more": false
}GET /v1/waitlists/:id
One waitlist. 404 not_found for an id that does not exist or belongs to another account —
the two are deliberately indistinguishable.
// 200
{ "id": "8f1c…", "name": "my-launch", "created_at": "2026-08-04T10:00:00.000Z" }PATCH /v1/waitlists/:id
Rename a waitlist. name is the only field, and it is required. Names stay unique per account,
so renaming onto one that is taken is 409 conflict; renaming to the name it already has is
fine and changes nothing.
Nothing but the name moves. The id, every signup, their positions and their referral codes all survive, so a rename cannot break an integration.
// request
{ "name": "beta-cohort-2" }
// 200
{ "id": "8f1c…", "name": "beta-cohort-2", "created_at": "2026-08-04T10:00:00.000Z" }DELETE /v1/waitlists/:id
Delete a waitlist. Returns 204 with an empty body.
This deletes every signup on the waitlist along with it, plus its custom field definitions and its
signup email copy. It is not reversible and there is no backup — fetch
/v1/waitlists/:id/signups/export first if you want the addresses.
There is no "must be empty first" rule, and no confirmation step in the API. It deletes what you point it at, so put the confirmation in front of the call.
Deleting frees the waitlist against your plan's waitlist limit immediately.
Not available over MCP
The MCP server exposes no delete-waitlist tool. This endpoint is reachable from your own code and from the SDK, where the call is a line somebody wrote and reviewed — not one an agent elected to make mid-loop.
POST /v1/waitlists/:id/signups
Add a signup to a waitlist. Returns immediately with confirmation (id, position, total, referral_code). Idempotent: same email always returns the same position, no duplicate signup emails. Triggers signup email send.
// request
{
"email": "user@example.com",
"utm_source": "producthunt",
"referrer": "https://…",
"referral_code": "k3f9x2mqbr",
"metadata": { "name": "Ada Lovelace", "company": "Acme", "plan": "pro" },
"fields": { "company": "Acme", "headcount": 50, "is_enterprise": false }
}
// 201
{ "id": "1a2b…", "position": 47, "total": 1203, "referral_code": "8mzq3wrt5k" }utm_source and referrer are optional. If referrer is omitted the Referer header is used.
metadata is an optional object for arbitrary structured data (name, company, use case, plan tier, etc).
Constraints: max 10 keys; keys must be alphanumeric + underscores, max 64 chars; values max 1000 chars.
Metadata is stamped at creation and never updated — re-signing up with different metadata returns the original signup unchanged.
fields is an optional object for values matching the waitlist's field definitions (see /v1/waitlists/:id/fields).
Required fields must be present; the type must match the definition (string, number, or boolean).
String values max 1000 chars. Unknown keys are ignored.
Newlines are allowed and render as line breaks in the signup email body. In the email subject they collapse to spaces, because a subject header cannot span lines.
referral_code in the request is somebody else's code — the one from the share link this
person followed. referral_code in the response is this signup's own, to put in front of them.
Both are optional to think about; see Referrals for what they do.
GET /v1/waitlists/:id/signups
Paginated, newest first. Query: limit (default 50, max 200), offset, email (exact match, for
looking up one position).
// 200
{
"data": [
{
"id": "1a2b…",
"email": "user@example.com",
"position": 47,
"utm_source": "producthunt",
"referrer": null,
"referral_code": "8mzq3wrt5k",
"referral_count": 3,
"referred_by": null,
"metadata": { "name": "Ada Lovelace", "company": "Acme" },
"fields": { "company": "Acme", "headcount": 50, "is_enterprise": false },
"created_at": "…",
"email_status": "delivered",
"email_delivered_at": "…",
"email_opened_at": null,
"email_clicked_at": null
}
],
"total": 1203,
"limit": 50,
"offset": 0,
"has_more": true
}GET /v1/waitlists/:id/signups/export
Returns all signups as a CSV file. Requires the signups:read scope.
Columns in order: position, email, created_at, utm_source, referrer, email_status,
email_delivered_at, email_opened_at, email_clicked_at, metadata (JSON), id,
referral_code, referral_count, referred_by.
New columns are appended, never inserted, so parsing by index keeps working across releases.
The response streams row-by-row — no memory cliff for large lists. The Content-Disposition
header triggers a browser file download automatically.
// 200 text/csv
position,email,created_at,utm_source,...
1,alice@example.com,2026-01-01T00:00:00.000Z,producthunt,...Errors (401, 403, 404) are returned as JSON before streaming begins, so a non-200 status always means no CSV data was sent.
DELETE /v1/waitlists/:id/signups/:signupId
Removes a signup. Returns 204. Everyone else keeps their position — positions come from a
monotonic counter, not from row order.
GET /v1/waitlists/:id/stats
Daily buckets, UTM source breakdown, email engagement rates, 7-day growth, and top referrer
domains. Query: from, to as YYYY-MM-DD; defaults to the last 30 days. The daily series is
zero-filled, so it charts as-is.
Every aggregate is windowed to the same range: daily and by_source both describe
range_from … range_to, and range_total is the number they sum to. total is the
size of the whole list, all time, whatever range you asked for.
Plans with an analytics history cap (Free, 30 days) have range_from pulled forward
rather than the request rejected — check range_from in the response if you asked for
more. Only the aggregation window is capped; no signup record is ever deleted, and
GET /signups and CSV export stay complete.
// 200
{
"waitlist_id": "8f1c…",
"total": 1203,
"range_total": 1203,
"range_from": "2026-07-06",
"range_to": "2026-08-04",
"daily": [
{ "date": "2026-07-06", "signups": 0 },
{ "date": "2026-07-07", "signups": 34 }
],
"by_source": [
{ "source": "producthunt", "signups": 812 },
{ "source": "direct", "signups": 391 }
],
"email_engagement": {
"emails_sent": 1100,
"delivery_rate": 94,
"open_rate": 41,
"click_rate": 12
},
"growth": {
"last_7": 38,
"prev_7": 31,
"delta": 23
},
"referrer_domains": [
{ "domain": "news.ycombinator.com", "signups": 640 },
{ "domain": "twitter.com", "signups": 172 }
]
}Signups with no utm_source are grouped under direct. email_engagement rates are integers
(0–100); delivery_rate, open_rate, and click_rate are null when no emails have been sent.
growth.delta is null when the prior 7-day window had zero signups. referrer_domains contains up to 5 entries extracted from the referrer field (body value wins;
HTTP Referer header is the fallback). Empty when none was captured or none were valid http:///https:// URLs.
Referrals
Every signup is issued a referral_code at creation: ten characters of lowercase
Crockford base32 — 0-9 and a-z minus i, l, o and
u — so it is URL-safe, and unambiguous read off a screen or aloud. It never changes, and
re-signing up returns the same one.
Show it as a share link, https://yoursite.com/?ref=8mzq3wrt5k, and pass whatever arrives in ?ref
back as referral_code on the next signup. Codes are matched leniently: case is ignored, hyphens
are stripped, and O/I/L are read as 0/1/1, so a code retyped off a screenshot still lands.
A credited referral does two things — the referrer's referral_count goes up by one, and the new
signup's referred_by points at them. Both happen inside the same transaction as the insert, so the
count never disagrees with the rows behind it. Deleting a referred signup gives the credit back.
A bad code is never an error. Each of these is silently ignored and the signup proceeds exactly as if no code had been sent — the person following a broken link is not the person who broke it:
| Case | Why it is dropped |
|---|---|
| Unknown or malformed code | Links get truncated, retyped, and mangled |
| Code belongs to a different waitlist | Codes are unique platform-wide; crediting is not |
| Code belongs to the email signing up | No self-referral |
| That email is already on the list | The referrer is stamped at creation, never updated |
Enlist records who referred whom and keeps the count. What a referral is worth is yours to decide — nothing here touches anyone's position, because positions come from a monotonic counter and never change. What people build on it:
- Priority access — sort by
referral_count DESC, position ASCwhen deciding who gets in first. - "Move up" display — show
position - referral_count * 10on the confirmation page. You pick the multiplier; the stored position is untouched. - Evangelists — filter
referral_count >= 5and go talk to them. - Unlock tiers — check
referral_count >= 3and flip your own flag.
Read all three fields off GET /v1/waitlists/:id/signups.
GET /v1/waitlists/:id/fields
List all field definitions for a waitlist, ordered by position then created_at.
// 200
{
"data": [
{
"key": "company",
"label": "Company name",
"type": "string",
"required": true,
"position": 0
},
{ "key": "headcount", "label": "Team size", "type": "number", "required": false, "position": 1 }
],
"total": 2,
"limit": 2,
"offset": 0,
"has_more": false
}POST /v1/waitlists/:id/fields
Create a field definition. key is immutable after creation and must be lowercase alphanumeric + underscores (e.g. company). Keys must not shadow built-in template variables (email, position, total, waitlist_name).
// request
{ "key": "company", "label": "Company name", "type": "string", "required": true, "position": 0 }
// 201
{ "key": "company", "label": "Company name", "type": "string", "required": true, "position": 0 }Returns 409 if a field with that key already exists on the waitlist.
A waitlist can define at most 20 fields; the 21st returns invalid_request. The cap is the same on every plan — delete a definition to make room. Existing definitions are never affected.
PATCH /v1/waitlists/:id/fields/:key
Update a field definition. Only label, required, and position can be changed — key and type are immutable.
// request
{ "label": "Company (legal name)", "required": false }
// 200
{ "key": "company", "label": "Company (legal name)", "type": "string", "required": false, "position": 0 }DELETE /v1/waitlists/:id/fields/:key
Delete a field definition. Returns 204. Existing signup fields values are preserved — only the definition is removed.
Errors
{ "error": { "code": "rate_limited", "message": "Too many signups. Retry in 42s." } }| Code | Status | Means |
|---|---|---|
invalid_request | 400 | Body or query failed validation |
unauthorized | 401 | Missing, invalid, or revoked API key |
quota_exceeded | 402 | Stored-signup limit for your plan reached |
forbidden | 403 | Key is valid but not allowed to perform this operation |
not_found | 404 | No such waitlist or signup on this account |
conflict | 409 | Name already exists (waitlist) or key already exists (field) |
rate_limited | 429 | Rate limit hit; see the Retry-After header |
internal_error | 500 | Unexpected server-side failure |
quota_exceeded is a 402 rather than a 429 on purpose: a rate limit clears on its own, a
full list does not, so retrying is pointless until you delete some signups or upgrade.
Rate limits
Two separate limits:
- Per account, across every endpoint. 60 requests/minute on Free, 300 on Launch, 1,000 on Scale, custom on Enterprise. The budget belongs to the account, not the key — extra keys do not buy extra throughput.
- Per IP on the signup endpoint. 20 requests/minute, keyed by waitlist and caller IP. The API key is still the real gate; this exists so that proxying signups from your own frontend gets bot-spam protection without you building it.
Either returns 429 with a Retry-After header giving the seconds until the window resets.
Webhooks out
Configure an endpoint in Settings → Webhook. Enlist POSTs a signed JSON body to your URL for each event. Every request carries two headers:
enlist-timestamp— Unix seconds at delivery timeenlist-signature—v1=<hex HMAC-SHA256 of "<timestamp>.<body>">
Reject payloads older than a few minutes to prevent replay attacks. Delivery is best-effort with no automatic retries — treat a missed event as possible and reconcile with the API if needed.
Event types
| Type | When |
|---|---|
signup.created | A new signup lands (not fired on duplicate re-signup) |
signup.removed | A signup is deleted via DELETE /v1/waitlists/:id/signups/:signupId |
signup.email_delivered | Signup email confirmed delivered |
signup.email_opened | Signup email opened |
signup.email_clicked | Link in signup email clicked |
All events share the envelope shape { "type": "<event-type>", "data": { ... } }.
signup.created and signup.removed data:
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Signup ID |
waitlist_id | string (UUID) | |
email | string | |
position | number | Monotonic, never reused |
metadata | object | Custom fields passed at signup time |
created_at | ISO 8601 | |
removed_at | ISO 8601 | signup.removed only |
signup.email_* data:
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Signup ID |
waitlist_id | string (UUID) | |
email | string | |
position | number | |
occurred_at | ISO 8601 | When the email event was recorded by the provider |
You can filter which events your endpoint receives in the webhook settings. Unchecking all types is treated as subscribing to everything.
Webhooks in
Two inbound routes, both authenticated by the provider's signature rather than an API key:
POST /v1/webhooks/resend— events for mail Enlist sent on its own credentials.POST /v1/webhooks/email/:token— events for mail sent with your own Resend key. The token in the path only selects which account's signing secret to verify against; the signature is what authenticates. Settings shows the URL to paste into Resend.
Either way the event is stamped onto the matching signup, which is where the dashboard's delivery and open columns come from.