Signup Forms
Embed forms on your website to collect contacts. Submissions feed into the same Contacts API, with optional double opt-in confirmation and a contact.subscribed webhook event.
How it works
- Create a form in the dashboard at
/formsand customize fields, button colour, and double opt-in behaviour. - Copy one of two embed snippets (plain HTML
<form>or a JS widget) and paste it on your site. - When a visitor submits, the public endpoint
/api/forms/{id}/submitvalidates input and either upserts the contact directly (DOI off) or sends a confirmation email (DOI on). - On confirmation (or direct add) we fire the
contact.subscribedwebhook event so your downstream automations can pick up.
Form Object
{
"object": "signup_form",
"id": "clx_form123",
"name": "Newsletter signup",
"fields": ["email", "first_name"],
"button_text": "Subscribe",
"button_color": "#10b981",
"success_message": "Thanks for subscribing!",
"double_opt_in": true,
"redirect_url": null,
"captcha_enabled": false,
"captcha_provider": "turnstile",
"turnstile_site_key": null,
"turnstile_secret_configured": false,
"block_disposable_emails": true,
"active": true,
"created_at": "2026-05-01T12:00:00.000Z",
"updated_at": "2026-05-01T12:00:00.000Z"
}/v1/formsCreate a new signup form, with its welcome email if you want one. Request keys are snake_case, like the form object; the older camelCase keys (buttonText, doubleOptIn) still work. Requires the form:write scope.
Request Body
{
"name": "Newsletter signup",
"fields": ["email", "first_name"],
"button_text": "Subscribe",
"button_color": "#10b981",
"double_opt_in": true,
"captcha_enabled": false,
"block_disposable_emails": true,
"welcome_email_enabled": true,
"welcome_email_from": "Acme <[email protected]>",
"welcome_email_subject": "Welcome to Acme",
"welcome_email_markdown": "Thanks for signing up, {{first_name}}."
}Response
{
"object": "signup_form",
"id": "clx_form123",
"name": "Newsletter signup",
"fields": ["email", "first_name"],
"button_text": "Subscribe",
"button_color": "#10b981",
"success_message": "Thanks for subscribing!",
"double_opt_in": true,
"redirect_url": null,
"captcha_enabled": false,
"captcha_provider": "turnstile",
"turnstile_site_key": null,
"turnstile_secret_configured": false,
"block_disposable_emails": true,
"active": true,
"confirmation_email_from": null,
"welcome_email_enabled": true,
"welcome_email_from": "Acme <[email protected]>",
"welcome_email_subject": "Welcome to Acme",
"welcome_email_markdown": "Thanks for signing up, {{first_name}}.",
"welcome_stats": { "sent": 0, "skipped": 0, "delivered": 0, "opened": 0, "clicked": 0 },
"pending_confirmations_count": 0,
"recent_pending_confirmations": [],
"created_at": "2026-05-01T12:00:00.000Z",
"updated_at": "2026-05-01T12:00:00.000Z"
}/v1/formsList all signup forms for the current account. Requires the form:read scope.
Response
{
"object": "list",
"has_more": false,
"data": [
{
"object": "signup_form",
"id": "clx_form123",
"name": "Newsletter signup",
"fields": ["email", "first_name"],
"button_text": "Subscribe",
"button_color": "#10b981",
"success_message": "Thanks for subscribing!",
"double_opt_in": true,
"redirect_url": null,
"captcha_enabled": false,
"captcha_provider": "turnstile",
"turnstile_site_key": null,
"turnstile_secret_configured": false,
"block_disposable_emails": true,
"active": true,
"created_at": "2026-05-01T12:00:00.000Z",
"updated_at": "2026-05-01T12:00:00.000Z"
}
]
}/v1/forms/{id}Update a form. Any field that's omitted is left unchanged. Requires form:write.
Request Body
{
"button_color": "#f97316",
"active": false
}Response
{
"object": "signup_form",
"id": "clx_form123",
"name": "Newsletter signup",
"fields": ["email", "first_name"],
"button_text": "Subscribe",
"button_color": "#f97316",
"success_message": "Thanks for subscribing!",
"double_opt_in": true,
"redirect_url": null,
"captcha_enabled": false,
"captcha_provider": "turnstile",
"turnstile_site_key": null,
"turnstile_secret_configured": false,
"block_disposable_emails": true,
"active": false,
"confirmation_email_from": null,
"welcome_email_enabled": true,
"welcome_email_from": "Acme <[email protected]>",
"welcome_email_subject": "Welcome to Acme",
"welcome_email_markdown": "Thanks for signing up, {{first_name}}.",
"welcome_stats": { "sent": 12, "skipped": 0, "delivered": 12, "opened": 7, "clicked": 2 },
"pending_confirmations_count": 0,
"recent_pending_confirmations": [],
"created_at": "2026-05-01T12:00:00.000Z",
"updated_at": "2026-05-03T08:30:00.000Z"
}/v1/forms/{id}Delete a signup form. Embedded snippets pointing at this form will stop working immediately.
Response
{
"object": "signup_form",
"id": "clx_form123",
"deleted": true
}/v1/forms/{id}/pendingList signups that have not clicked their confirmation link yet, newest first. Each one shows the status of its confirmation email, the country it came from and the page it was submitted on. Supports page, per_page (up to 100) and q. Requires form:read.
Response
{
"object": "list",
"has_more": false,
"data": [
{
"id": "clx_pending123",
"email": "[email protected]",
"first_name": "Jane",
"last_name": null,
"created_at": "2026-09-22T19:12:51.000Z",
"expires_at": "2026-09-29T19:12:51.000Z",
"country": "US",
"source_url": "https://example.com/blog/launch?utm_source=newsletter",
"confirmation_email": { "id": "clx_email123", "status": "delivered" }
}
],
"total": 9,
"undeliverable_total": 1,
"tor_total": 2,
"approvable_total": 6,
"page": 1,
"per_page": 20
}/v1/forms/{id}/pending/{pendingId}/approveConfirm one pending signup without waiting for the link click. It becomes a contact, fires contact.subscribed, and gets the welcome email if enabled. Requires form:write.
Response
{
"approved": true,
"contact_id": "clx_contact123",
"email": "[email protected]"
}/v1/forms/{id}/pending/approve-allConfirm pending signups in batches of up to 200; call again while remaining is above zero. Signups whose confirmation email bounced, was marked as spam or failed are left pending and counted in skipped_undeliverable; signups submitted through Tor are left pending and counted in skipped_tor. Optional body: { q } to approve only matching signups. Requires form:write.
Response
{
"approved": 8,
"skipped": 0,
"remaining": 0,
"skipped_undeliverable": 1,
"skipped_tor": 2
}/v1/forms/{id}/pending/{pendingId}Remove a pending signup without adding it as a contact. Its confirmation link stops working. Requires form:write.
Response
{
"deleted": true
}Embedding on your site
The dashboard generates two snippets per form. Pick whichever fits your site.
Plain HTML form (no JavaScript)
Submits as a regular <form> POST. The browser navigates to a thanks page (or your redirect_url) after the request.
<form action="https://smtpfa.st/api/forms/clx_form123/submit" method="POST"> <input type="email" name="email" placeholder="[email protected]" required /> <input type="text" name="first_name" placeholder="First name" /> <input type="text" name="_hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px"/> <button type="submit">Subscribe</button> </form>
JavaScript widget (recommended)
A small async script renders the form into the placeholder div, posts via fetch, and shows the success message inline. Style overrides come from your form config so dashboard edits propagate without re-pasting the snippet.
<div data-smtpfast-form="clx_form123"></div> <script src="https://smtpfa.st/api/forms/clx_form123/embed.js" async></script>
Public submission endpoint
The submission endpoint is intentionally anonymous (no API key) so it can be called from unprivileged web pages. Authorization is implicit in the form ID.
/api/forms/{id}/submitPublic form submission. Accepts both application/json and application/x-www-form-urlencoded bodies. CORS is open. Rate-limited to 10 requests / minute / IP / form. The honeypot field (_hp), disposable-email filter, per-email confirmation throttle, and optional Turnstile verification reduce spam.
Request Body
{
"email": "[email protected]",
"first_name": "Steve"
}Response
{
"success": true,
"message": "Almost done! We sent a confirmation link to [email protected]."
}Double opt-in flow
When double_opt_in is true (recommended), submissions are stashed in a pending table for up to 7 days while we email a confirmation link. The contact only appears in your account — and the contact.subscribed webhook only fires — after the recipient clicks through.
- Confirmation links are signed with HMAC-SHA256, so tampered tokens are rejected without a DB lookup.
- Re-submitting the same email replaces the pending row — only the most recent confirmation link works.
- Re-clicking a successful confirmation link is idempotent; it won't create a duplicate contact.
- Confirmation emails come from your form's sender when its domain is verified, and from a SMTPfast-managed sender otherwise, so forms work even before you've verified a domain.
- The form page lists each pending signup with its confirmation email status (delivered, opened, bounced), the country it came from and the page it was submitted on. You can approve a signup by hand or remove it. Approve all leaves out addresses whose confirmation email bounced, and signups made through Tor.
- For the country we keep only the two-letter code from Cloudflare, never the IP address. Signups made through the Tor network are shown as Tor, with a warning: scripts that enter other people's addresses into many forms at once usually arrive that way. Turning on Turnstile stops them. For the page we keep the address and any
utm_*parameters, and drop the rest of the query string.
Welcome email
Set welcome_email_enabled and the form sends one email to each new contact, once. With double opt-in it goes after the confirmation, not before. The body is Markdown, in welcome_email_markdown.
{{first_name}}and{{email}}are substituted before sending. Give a default with a pipe:{{first_name|there}}.{{unsubscribe_url}}becomes a per-recipient link. Put it inside a Markdown link,[Unsubscribe]({{unsubscribe_url}}), rather than bare.- Welcome emails are marketing email, so every one carries
List-Unsubscribeand one-click unsubscribe headers whether or not the body has a link, and the link is optional rather than required. - The token works in the body, not the subject.
- Sent once per contact per form, and skipped for contacts already unsubscribed or suppressed. The same warmup, abuse and quota gates apply as any other send.
contact.subscribed webhook
Subscribe to this event on any of your webhooks to receive notifications when a contact joins via a form (or via direct API upsert from the public submit endpoint).
{
"type": "contact.subscribed",
"data": {
"contact_id": "clx_contact123",
"email": "[email protected]",
"first_name": "Steve",
"last_name": null,
"form_id": "clx_form123",
"double_opt_in": true,
"source": "confirm_link"
},
"timestamp": "2026-05-01T12:00:00.000Z"
}With double opt-in, source says how the signup was confirmed: confirm_link when the person clicked the link in the confirmation email, manual when you approved it from the dashboard or the API. A form without double opt-in sends double_opt_in: false and no source.
Bot protection & rate limits
- Honeypot: Forms include a hidden
_hpfield. Real users don't fill it; bots usually do. Tripped submissions return a normal-looking success without storing anything. - Rate limit: 10 requests per minute per (form, IP). Excess returns HTTP 429.
- Confirmation throttle: Double opt-in emails are capped per (form, email) to slow confirmation-mail abuse.
- Cloudflare Turnstile: Enable CAPTCHA in the form builder by adding your Turnstile site key and secret key. Tokens are validated server-side before contacts or pending confirmations are created.
- Disposable email filter: Common temporary inbox domains are silently ignored by default.
- Suppression list: Submissions for addresses already on the form-owner's suppression list (previously bounced or complained) are silently dropped.
- Confirmation expiry: Pending DOI tokens are valid for 7 days. After that the submitter must resubmit.
Preview the welcome email
Render a form's welcome email for a sample contact without sending it. Pass subject and markdown to preview unsaved changes.
/v1/forms/{id}/welcome-previewRender the welcome email.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| subject | string | No | Subject to preview |
| markdown | string | No | Body to preview |
| sample | object | No | email, firstName and lastName used for personalization |
Request Body
{ "markdown": "Hi {{first_name}}, thanks for joining." }Response
{ "subject": "Welcome", "html": "<p>Hi Sample, thanks for joining.</p>", "text": "Hi Sample, thanks for joining." }