Developer docs
Collect signups from any site with a single POST. Create a campaign, add the domains you'll embed on, then point a form at your subscribe endpoint.
Need field-level detail? See the API reference. Machine-readable OpenAPI: /api/openapi.json.
Set up with an AI agent
Building your form with Lovable, ChatGPT, Claude, or another AI coding tool? Copy this prompt and paste it in — it points the agent at our machine-readable spec and your campaign id.
I'm integrating a signup form with Simple Signups, a hosted signup/waitlist backend (not a form builder — I bring my own HTML/UI).
Before building, read the integration spec at https://simple-signups.com/llms.txt (or the human docs at https://simple-signups.com/docs) — it has the exact endpoint, accepted request fields, and error codes. Don't invent any fields that aren't listed there.
My campaign id: pub_your_campaign_id
Build a form (plain HTML or a component, whichever fits my project) that POSTs to the subscribe endpoint from that spec using my campaign id above, includes the honeypot field, and handles the documented success and error responses.Quickstart
- 1Create a campaign and add the domains you'll embed on.
- 2Copy your campaign id (starts with
pub_) into the field below.
<form action="https://simple-signups.com/api/subscribe" method="POST"> <input type="hidden" name="campaignId" value="pub_your_campaign_id" /> <input type="email" name="email" required placeholder="you@example.com" /> <!-- Optional tags: comma/semicolon/pipe-separated slugs --> <input type="hidden" name="tags" value="newsletter" /> <input type="text" name="hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" /> <button type="submit">Subscribe</button></form>Subscribe endpoint
Send a POST request to create a new subscriber. Accepts application/json or application/x-www-form-urlencoded. All requests must include your campaign ID.
POSThttps://simple-signups.com/api/subscribe
| Field | Required | Description |
|---|---|---|
| campaignId | Yes | Your public campaign id (starts with pub_). |
| Yes | The subscriber email address. | |
| firstName | No | Optional given name. |
| lastName | No | Optional family name. |
| tags | No | Array of short category slugs, or a comma/semicolon/pipe string. Max 20; re-subscribe merges (unions) tags. |
| metadata | No | Object of extra string/number/boolean fields (size-capped). |
| redirect | No | No-JS success URL; must be an allowed domain. |
| hp | No | Honeypot — leave empty; filled requests are dropped. |
| turnstileToken | If enabled | Bot-challenge token (only when bot protection is enabled). |
Full field catalogue and error codes can be found in the API reference.
Examples
curl -X POST https://simple-signups.com/api/subscribe \ -H 'content-type: application/json' \ -d '{"campaignId":"pub_your_campaign_id","email":"jane@example.com","tags":["newsletter"]}'Path-style endpoint
Prefer a tidy URL? This takes the campaign id from the path, so the body only needs the email (and any optional fields). A campaignId in the body is allowed but must match the path.
POSThttps://simple-signups.com/c/:publicCampaignId
curl -X POST https://simple-signups.com/c/pub_your_campaign_id \ -H 'content-type: application/json' \ -d '{"email":"jane@example.com","tags":["newsletter"]}'Metadata & extra fields
Attach an optional metadata object of string, number, or boolean values. It's stored with the subscriber, shown in the dashboard, and included in CSV/JSON exports. Keys and total size are capped.
await fetch('https://simple-signups.com/api/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ campaignId: 'pub_your_campaign_id', email: 'jane@example.com', tags: ['customer'], metadata: { plan: 'pro', referredBy: 'newsletter', seats: 3 }, }),});Export subscribers
Your list is yours. Download a campaign's subscribers as CSV or JSON from the dashboard at any time, on every plan. Exports include tags and metadata. Exporting a filtered segment (by search, status, or tag) is a Pro feature.
To export from a script, call GET /c/:publicCampaignId/subscribers/export with an API key that has the export:campaign scope (see API keys). Add ?format=json for JSON; CSV is the default.
Analytics
Each campaign has a stats view in the dashboard: daily submission attempts, how many were stored and how many were blocked, grouped by reason (spam and bots, wrong site, bad email), plus the confirmation rate for double opt-in and top referrers. Blocked attempts are counted without storing the submitted email.
Free plans show the last 7 days of history and Pro shows 30 days. Pro can also download the daily series as CSV.
API keys
Create scoped ss_... keys in your dashboard under Integrations. Send them as a Bearer token. Each key is shown once and can be revoked at any time. Scopes: submit:campaign, read:subscribers, export:campaign, write:subscribers (tag PATCH / bulk-tags).
# Submit with an API key (scope: submit:campaign)curl -X POST https://simple-signups.com/c/pub_your_campaign_id \ -H 'authorization: Bearer ss_your_token' \ -H 'content-type: application/json' \ -d '{"email":"jane@example.com","tags":["api","newsletter"]}'
# Read subscribers (scope: read:subscribers) — optional ?tag=vip filtercurl 'https://simple-signups.com/c/pub_your_campaign_id/subscribers?tag=newsletter' \ -H 'authorization: Bearer ss_your_token'
# Export a segment (scope: export:campaign)curl 'https://simple-signups.com/c/pub_your_campaign_id/subscribers/export?format=csv&tag=newsletter' \ -H 'authorization: Bearer ss_your_token'
# Patch tags on one contact (scope: write:subscribers)curl -X PATCH https://simple-signups.com/c/pub_your_campaign_id/subscribers/sub_... \ -H 'authorization: Bearer ss_your_token' \ -H 'content-type: application/json' \ -d '{"addTags":["vip"],"removeTags":["waitlist"]}'
# Bulk add/remove tags (scope: write:subscribers)curl -X POST https://simple-signups.com/c/pub_your_campaign_id/subscribers/bulk-tags \ -H 'authorization: Bearer ss_your_token' \ -H 'content-type: application/json' \ -d '{"ids":["sub_..."],"addTags":["vip"]}'Webhooks
Register outbound webhooks per campaign to receive a signed POST on subscriber.created and subscriber.confirmed. Each delivery carries an X-Simple-Signups-Signature header (t= timestamp, v1= HMAC-SHA256 of timestamp.body) signed with the secret shown once at creation. A slack kind posts a readable message to a Slack Incoming Webhook.
// Verify X-Simple-Signups-Signature (t=timestamp,v1=hex hmac-sha256)import { createHmac } from 'node:crypto';
function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('='))); const expected = createHmac('sha256', secret) .update(`${parts.t}.${rawBody}`) .digest('hex'); return expected === parts.v1;}Email tools
Connect Buttondown, Kit, Loops, Resend or a self-hosted Mailcheap to a campaign from its Integrations tab and every confirmed subscriber is added there automatically — already confirmed, so the tool doesn't send its own opt-in email (Mailcheap newsletters that confirm signups are the exception). Pick an optional list, segment or tag to add them to.
- API keys are checked with the provider when you save them, stored encrypted, and never shown again.
- Failed syncs retry with backoff for about an hour; the campaign shows the last error (for example a revoked key) and each tool has an activity log.
- Deleting a subscriber here removes them from the chosen list, segment or tag, or unsubscribes them if none is set. Contacts are never deleted from your email tool. Mailcheap has no removal API, so remove them there directly.
Where to create the key: Buttondown API key · Kit API key · Loops API key · Resend API key · Mailcheap API key
Error codes
| Code | HTTP | When |
|---|---|---|
| VALIDATION_ERROR | 400 | Missing/invalid fields or malformed body. |
| EMAIL_UNDELIVERABLE | 400 | Email domain has no usable MX records. |
| DOMAIN_NOT_ALLOWED | 403 | Origin/redirect not in the allow-list. |
| BOT_CHECK_FAILED | 403 | Bot-challenge verification failed. |
| UNAUTHORIZED | 401 | Missing or invalid API key on an authenticated endpoint. |
| FORBIDDEN | 403 | API key lacks the scope required for the endpoint. |
| CAMPAIGN_NOT_FOUND | 404 | Unknown campaignId. |
| PAYLOAD_TOO_LARGE | 413 | Body exceeds the 4 KiB cap. |
| RATE_LIMITED | 429 | Per-IP or per-campaign rate limit hit. |
| TOKEN_INVALID | 410 | Confirmation token expired or already used. |
| INTERNAL | 500 | Unexpected server error. |
Anti-abuse & domains
- Domain allow-list: requests are accepted only from a campaign's configured domains (checked via Origin/Referer); others get
DOMAIN_NOT_ALLOWED. - Honeypot: include the hidden
hpfield; filled submissions are silently discarded. - Rate limits: per-IP and per-campaign; back off on
RATE_LIMITEDusing theRetry-Afterheader. - Turnstile (Pro, opt-in per campaign): off by default so it never blocks a campaign that hasn't set it up. Turn it on from a campaign's settings and we automatically add that campaign's allowed domains to the shared Turnstile widget's allow-list — the campaign's Setup tab then shows the widget snippet to add to your form. When it's on, send the widget's token as
turnstileToken; verification failures returnBOT_CHECK_FAILED. - Double opt-in: confirmation-required campaigns email a link; subscribers stay
pendinguntil confirmed.