Enlist

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.

ScopeGrants
account:readGET /v1/account
account:writeEvery write under /v1/integrations — PUT, PATCH, DELETE
waitlists:readGET /v1/waitlists, GET /v1/waitlists/:id, its /stats and its /fields
waitlists:writePOST /v1/waitlists, PATCH /v1/waitlists/:id, and every write under /fields
waitlists:deleteDELETE /v1/waitlists/:id
signups:readGET /v1/waitlists/:id/signups and its /export
signups:writePOST /v1/waitlists/:id/signups
signups:deleteDELETE /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",
  "referral_bonus": 5,
  "created_at": "2026-08-04T10:00:00.000Z"
}

referral_bonus is the number of positions a signup moves up per successful referral when computing display_position. null when the feature is off.

PATCH /v1/waitlists/:id

Update a waitlist. At least one field must be provided.

name renames the waitlist. 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. The id, every signup, their positions and their referral codes all survive a rename.

referral_bonus sets how many positions a signup moves up per successful referral. Set to null to disable. The real position is never changed — only display_position is affected.

// request — rename only
{ "name": "beta-cohort-2" }

// request — set referral bonus
{ "referral_bonus": 5 }

// request — clear referral bonus
{ "referral_bonus": null }

// 200
{ "id": "8f1c…", "name": "beta-cohort-2", "referral_bonus": 5, "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,
      "display_position": 32,
      "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 }
  ],
  "referred_count": 42
}

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:

CaseWhy it is dropped
Unknown or malformed codeLinks get truncated, retyped, and mangled
Code belongs to a different waitlistCodes are unique platform-wide; crediting is not
Code belongs to the email signing upNo self-referral
That email is already on the listThe referrer is stamped at creation, never updated

Enlist records who referred whom and keeps the count. What a referral is worth is mostly yours to decide — a stored position comes from a monotonic counter and never changes, whatever anyone refers. What people build on it:

  • Priority access — sort by referral_count DESC, position ASC when deciding who gets in first.
  • "Move up" display — set referral_bonus on the waitlist and read display_position off the signup, which is max(1, position - referral_count * referral_bonus). You pick the multiplier; the stored position is untouched, and display_position is null until you set one.
  • Evangelists — filter referral_count >= 5 and go talk to them.
  • Unlock tiers — check referral_count >= 3 and 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, referral_code, referral_bonus).

// 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." } }
CodeStatusMeans
invalid_request400Body or query failed validation
unauthorized401Missing, invalid, or revoked API key
quota_exceeded402Plan limit: stored signups, or a feature the plan lacks
forbidden403Key is valid but not allowed to perform this operation
not_found404No such waitlist or signup on this account
conflict409Name already exists (waitlist) or key already exists (field)
rate_limited429Rate limit hit; see the Retry-After header
internal_error500Unexpected 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. It is also what configuring an integration returns on Free — the key is allowed to do it and the body is fine, the plan is what is missing, so 403 would send you to the wrong page.

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 on the webhook integration page. Enlist POSTs a signed JSON body to your URL for each event. Every request carries two headers:

  • enlist-timestamp — Unix seconds at delivery time
  • enlist-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

TypeWhen
signup.createdA new signup lands (not fired on duplicate re-signup)
signup.removedA signup is deleted via DELETE /v1/waitlists/:id/signups/:signupId
signup.email_deliveredSignup email confirmed delivered
signup.email_openedSignup email opened
signup.email_clickedLink in signup email clicked

All events share the envelope shape { "type": "<event-type>", "data": { ... } }.

signup.created data:

FieldTypeNotes
idstring (UUID)Signup ID
waitlist_idstring (UUID)
waitlist_namestringDisplay name of the waitlist
emailstring
positionnumberMonotonic, never reused
totalnumberLive signup count at the moment of creation
referral_codestringThis signup's own code, to share
referred_bystring (UUID) | nullSignup ID of the referrer, or null if none
utm_sourcestring | nullAs sent at signup, or null
metadataobjectCustom fields passed at signup time
created_atISO 8601

signup.removed data:

FieldTypeNotes
idstring (UUID)Signup ID
waitlist_idstring (UUID)
waitlist_namestringDisplay name of the waitlist
emailstring
positionnumberMonotonic, never reused
metadataobjectCustom fields passed at signup time
created_atISO 8601
removed_atISO 8601

signup.email_* data:

FieldTypeNotes
idstring (UUID)Signup ID
waitlist_idstring (UUID)
waitlist_namestringDisplay name of the waitlist
emailstring
positionnumber
occurred_atISO 8601When the email event was recorded by the provider

You can filter which events your endpoint receives on the webhook integration page. Unchecking all types is treated as subscribing to everything.

Testing an endpoint

The webhook integration page has a Send test event button. It POSTs a synthetic signup.created through the same dispatcher a real event uses — same URL, same signing secret, same headers — so a signature that verifies here verifies in production. The payload's metadata is { "enlist_test": true }, which is the one thing that distinguishes it from a real signup on the wire.

It tests the endpoint you have saved; there is no way to point it at a URL typed into a box, and no API equivalent. A paused webhook is still testable, since pausing is usually how a broken endpoint gets quiet.

Each test send writes a delivery record with is_test set to true, so the log below the form shows the result immediately and a consumer reconciling against /v1/integrations/:type/deliveries can tell test sends from traffic.

Managing webhooks over the API

The same configuration is reachable without the dashboard, under /v1/integrations. Supported types: webhook, slack, discord, loops, posthog, kit.

MethodPathScopeReturns
GET/v1/integrationsaccount:readArray of {type, enabled, created_at}
PUT/v1/integrations/:typeaccount:write{type, enabled, signing_secret}
PATCH/v1/integrations/:typeaccount:write{type, enabled, created_at}
DELETE/v1/integrations/:typeaccount:write204, no body
GET/v1/integrations/:type/deliveriesaccount:readThe 50 most recent attempts

Integrations need the Launch plan or higher. PUT, and PATCH with enabled: true, return 402 quota_exceeded on Free. The rest of the table works on every plan on purpose: an account that downgrades has to be able to list what it configured, pause it and delete it. Delivery itself stops at the downgrade — a config that outlives the plan does not keep firing.

PUT takes { "config": { ... } } and replaces the whole config. Config shapes by type:

webhook

{
  "config": {
    "url": "https://example.com/hooks/enlist",
    "events": ["signup.created", "signup.removed"]
  }
}

slack — posts Block Kit messages to an incoming webhook URL.

{
  "config": {
    "webhook_url": "https://hooks.slack.com/services/T.../B.../...",
    "events": ["signup.created", "signup.removed"]
  }
}

discord — posts embeds to a Discord webhook URL.

{
  "config": {
    "webhook_url": "https://discord.com/api/webhooks/...",
    "events": ["signup.created", "signup.email_opened"]
  }
}

For slack and discord, events is required and must be a non-empty array of valid event types. The webhook_url is write-only and never returned.

loops — creates or updates a Loops contact, and sends a Loops event for a click.

{
  "config": {
    "api_key": "your-loops-api-key",
    "mailing_list_id": "abc123",
    "events": ["signup.created", "signup.removed"]
  }
}

mailing_list_id is optional. events is required and must be non-empty, and cannot include signup.email_delivered or signup.email_opened — Loops records those from its own sends, so there is nothing to forward. api_key is write-only, and it is checked against the Loops API when you save it: a key it rejects comes back as invalid_request.

posthog — captures a PostHog event for each subscribed event type.

{
  "config": {
    "api_key": "phc_your_project_api_key",
    "host": "https://us.i.posthog.com",
    "events": ["signup.created", "signup.removed"]
  }
}

api_key must start with phc_. host defaults to https://us.i.posthog.com when omitted; set it to https://eu.i.posthog.com for EU cloud, or to a self-hosted instance reachable on the public internet — it goes through the same check as a webhook URL, so private ranges, localhost and link-local addresses are refused. No trailing slash. events is required and must name at least one event type.

kit — subscribes people to Kit, and untags or unsubscribes them on removal.

{
  "config": {
    "api_key": "your-kit-api-key",
    "form_id": "123456",
    "tag_ids": ["789"],
    "events": ["signup.created", "signup.removed"]
  }
}

form_id and tag_ids are optional; api_key and events are required, and events must be non-empty. With form_id set the subscriber is added to that form, otherwise straight to the account. Each id in tag_ids is applied after the subscribe succeeds, and a tag that fails is logged without failing the delivery — the person is on the list either way.

Each subscriber carries the enlist_position, enlist_waitlist_id and enlist_referral_code custom fields. Enlist creates them on your Kit account when the config is saved, because Kit discards values for fields it does not know.

api_key is write-only, and both it and the form are checked against Kit when you save them. An unusable key or form is a 400 with code invalid_request, like any other rejected field.

signup.removed never deletes the subscriber — a Kit delete is account-wide and takes their whole history with it. With tag_ids set, Enlist removes those tags; without them it unsubscribes the address, which you can reverse in Kit.

Config is never returned by any endpoint — it can hold credentials. Secret fields omitted from a PUT keep their stored value, so changing the URL does not mean resending a secret you cannot read back.

Config keys are snake_case, like every other field in this API, and a key the integration does not declare is a 400 naming it — Unknown config field: signingSecret. It is not ignored. Since config is stored as sent and read back on every delivery, a typo that saved quietly would go on being wrong for as long as the integration lives.

On the 201 that first creates a webhook without an explicit signing_secret, one is generated and returned as signing_secret. That is the only time it is ever sent; store it then, exactly as you would an API key. Updates return null.

Changing the URL clears the delivery log, since the attempts in it describe a different endpoint.

Pausing versus deleting. These are different operations and only one is reversible.

PATCH with { "enabled": false } stops delivery and keeps everything — config, signing secret, delivery history. PATCH it back to true and delivery resumes with the same secret, so no receiver has to change. A paused integration stays paused through a PUT: editing config is not a request to resume.

DELETE destroys the integration, its signing secret and its delivery log. There is no undo, and creating the webhook again issues a new signing secret that every receiver has to be updated with. Reach for PATCH unless you mean to discard all of it.

Delivery records:

FieldTypeNotes
idstring
event_typestringOne of the event types above
statussuccess | failed
response_statusnumber | nullThe HTTP status your endpoint returned. Null if it never answered
errorstring | nullSet when status is failed
created_atISO 8601When the attempt was made
delivered_atISO 8601 | nullNull on failure
is_testbooleanTrue for a dashboard test send rather than a real event

An unsupported type is a 404, not a 400 — no adapter exists to configure.

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.