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.
Quickstart
- Create a campaign in your dashboard and copy its public id.
- Add each website domain you'll embed on to the allow-list.
- Paste the HTML form below (swap in your campaign id) and submit a test.
<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
POST https://simple-signups.com/api/subscribe — accepts JSON or application/x-www-form-urlencoded. Full field catalogue and error codes live on the API reference.
| 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). |
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"]}'await fetch('https://simple-signups.com/api/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ campaignId: 'pub_your_campaign_id', email, tags: ['newsletter'], }),});async function onSubmit(e) { e.preventDefault(); await fetch('https://simple-signups.com/api/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ campaignId: 'pub_your_campaign_id', email: e.currentTarget.email.value, tags: ['newsletter'], }), });}Path-style endpoint
Prefer a tidy URL? POST /c/:publicCampaignId 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.
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 }, }),});API keys
Create scoped ss_live_... 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_live_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_live_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_live_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_live_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_live_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;}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. - Bot challenge: when enabled, send the challenge token; verification failures return
BOT_CHECK_FAILED. - Double opt-in: confirmation-required campaigns email a link; subscribers stay
pendinguntil confirmed.