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

  1. Create a form in the dashboard at /forms and customize fields, button colour, and double opt-in behaviour.
  2. Copy one of two embed snippets (plain HTML <form> or a JS widget) and paste it on your site.
  3. When a visitor submits, the public endpoint /api/forms/{id}/submit validates input and either upserts the contact directly (DOI off) or sends a confirmation email (DOI on).
  4. On confirmation (or direct add) we fire the contact.subscribed webhook event so your downstream automations can pick up.

Form Object

json
{
  "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"
}
POST/v1/forms

Create 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

json
{
  "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

json
{
  "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"
}
GET/v1/forms

List all signup forms for the current account. Requires the form:read scope.

Response

json
{
  "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"
    }
  ]
}
PATCH/v1/forms/{id}

Update a form. Any field that's omitted is left unchanged. Requires form:write.

Request Body

json
{
  "button_color": "#f97316",
  "active": false
}

Response

json
{
  "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"
}
DELETE/v1/forms/{id}

Delete a signup form. Embedded snippets pointing at this form will stop working immediately.

Response

json
{
  "object": "signup_form",
  "id": "clx_form123",
  "deleted": true
}
GET/v1/forms/{id}/pending

List 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

json
{
  "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
}
POST/v1/forms/{id}/pending/{pendingId}/approve

Confirm 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

json
{
  "approved": true,
  "contact_id": "clx_contact123",
  "email": "[email protected]"
}
POST/v1/forms/{id}/pending/approve-all

Confirm 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

json
{
  "approved": 8,
  "skipped": 0,
  "remaining": 0,
  "skipped_undeliverable": 1,
  "skipped_tor": 2
}
DELETE/v1/forms/{id}/pending/{pendingId}

Remove a pending signup without adding it as a contact. Its confirmation link stops working. Requires form:write.

Response

json
{
  "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.

html
<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.

html
<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.

POST/api/forms/{id}/submit

Public 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

json
{
  "email": "[email protected]",
  "first_name": "Steve"
}

Response

json
{
  "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-Unsubscribe and 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).

json
{
  "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 _hp field. 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.

POST/v1/forms/{id}/welcome-preview

Render the welcome email.

Parameters

ParameterTypeRequiredDescription
subjectstringNoSubject to preview
markdownstringNoBody to preview
sampleobjectNoemail, firstName and lastName used for personalization

Request Body

json
{ "markdown": "Hi {{first_name}}, thanks for joining." }

Response

json
{ "subject": "Welcome", "html": "<p>Hi Sample, thanks for joining.</p>", "text": "Hi Sample, thanks for joining." }