Audiences

Signup forms

Turn a link or an iframe into a subscriber pipeline — hosted, embeddable, and double-opt-in ready.

A signup form grows a contact list from the outside world. Create one, point it at a list, and Sendara gives you two ways to collect subscribers: a hosted page you can share as a link, and an embeddable widget you drop into any site with one <iframe>. Both render from the same config and post to the same public endpoint, so you never write the markup or wire up a backend yourself.

When someone submits, Sendara upserts a contact, adds it to the form's target list, and records the email consent — either subscribing them immediately, or, with double opt-in, emailing a confirmation link first. The CRUD endpoints live under /v1/forms; the visitor-facing endpoints live under /v1/public and need no API key.

Forms are email-only. The optional phone field is stored on the contact for your records, but Sendara only sends over email — there is no SMS or voice channel. To send to the list a form builds, use a broadcast.

The two surfaces

Every form has a randomly generated slug — a short, unguessable id like k3f9a1b2c7d4 — that keys both public surfaces. The create and get responses hand you a ready-to-use public_url and embed_codeso you don't have to assemble URLs by hand.

Form surfaces
SurfaceWhat it is
Hosted pageA standalone, branded page at /v1/public/f/{slug}. Share the link anywhere — it renders the full form and handles the submit.
Embedded widgetThe same page in iframe mode — /v1/public/f/{slug}?embed=1 — sent with frame-ancestors * so it can be embedded on any site. The embed_code field is a copy-paste <iframe> for exactly this.
Raw configA JSON-only render config at /v1/public/forms/{slug}, if you want to build your own UI and post to the submit endpoint directly.

Create a form

POST /v1/forms creates a form. Give it a name and a list_id that belongs to your account; everything else is optional. Toggle double_opt_in to require email confirmation, switch on the optional fields you want to collect, and set the settings that drive the rendered page. The form is created active — live immediately.

Request body

namestringRequired
A human label for the form, shown in the dashboard and list responses.
list_idstringRequired
The contact list every subscriber is added to. Must belong to your account, otherwise the call returns invalid_list (400).
double_opt_inbooleanOptional
When true, a submission creates a pending contact and emails a confirmation link; the subscription only completes after the link is clicked. Defaults to false.
fieldsobjectOptional
Per-field config for the optional contact fields — { first_name, last_name, phone }, each { enabled, required }. Email is always shown and required and is not listed here.
success_messagestringOptional
The message shown after a successful submission (when no redirect_url is set).
redirect_urlstringOptional
An http or https URL to send the visitor to after a successful submission, instead of showing success_message. Anything else returns invalid_request (400).
settingsobjectOptional
Presentation options that are safe to expose publicly — { heading, description, button_text, branding }.

The response is the created form, including its generated slug, the shareable public_url, and a paste-ready embed_code.

{
  "id": "form_a1b2c3",
  "slug": "k3f9a1b2c7d4",
  "name": "Newsletter signup",
  "list_id": "list_9f21",
  "double_opt_in": true,
  "fields": {
    "first_name": { "enabled": true, "required": false },
    "last_name":  { "enabled": false, "required": false },
    "phone":      { "enabled": false, "required": false }
  },
  "success_message": "Thanks for subscribing!",
  "redirect_url": "",
  "settings": { "heading": "Join the list", "description": "", "button_text": "Subscribe", "branding": true },
  "status": "active",
  "public_url": "https://api.sendara.dev/v1/public/f/k3f9a1b2c7d4",
  "embed_code": "<iframe src=\"https://api.sendara.dev/v1/public/f/k3f9a1b2c7d4?embed=1\" style=\"width:100%;max-width:460px;height:520px;border:0;\" loading=\"lazy\" title=\"Signup form\"></iframe>",
  "submission_count": 0,
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}
The slugis random and unguessable — not derived from the name — so it's safe to publish. To change the list a live form feeds, update its list_id; the public URL and embed code stay the same.

List, get, and update

GET /v1/forms returns your forms (newest first) under a forms array, each with a live submission_count.GET /v1/forms/{id} returns a single form with its full config.

curl https://api.sendara.dev/v1/forms \
  -H "Authorization: Bearer sk_live_xxx"
{
  "forms": [
    {
      "id": "form_a1b2c3",
      "slug": "k3f9a1b2c7d4",
      "name": "Newsletter signup",
      "list_id": "list_9f21",
      "double_opt_in": true,
      "status": "active",
      "submission_count": 128,
      "public_url": "https://api.sendara.dev/v1/public/f/k3f9a1b2c7d4",
      "created_at": "2026-06-14T10:00:00Z"
    }
  ]
}

PUT /v1/forms/{id} updates any field — the same body as create, every key optional, plus a status you set to active or inactive to publish or unpublish the form without deleting it. An inactive form returns not_found on every public surface, so unpublishing instantly takes the hosted page and embed offline.

curl -X PUT https://api.sendara.dev/v1/forms/form_a1b2c3 \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "status": "inactive" }'
status only accepts active or inactive; anything else returns invalid_request (400). Changing list_idto a list that isn't yours returns invalid_list (400).

Delete a form

DELETE /v1/forms/{id} permanently removes a form and returns 204 No Content. The contacts it has already collected stay on their list — deleting the form only retires the intake surface.

curl -X DELETE https://api.sendara.dev/v1/forms/form_a1b2c3 \
  -H "Authorization: Bearer sk_live_xxx"
To take a form offline temporarily, prefer setting status: "inactive" over deleting — you keep the slug, the config, and the option to republish later.

Read the public config

GET /v1/public/forms/{slug} is unauthenticated and returns only what a renderer needs — the heading, description, enabled fields, button text, success message, and the double_opt_in flag. It deliberately omits your account id, the target list, and submission counts, so nothing internal leaks through the public surface. Use it when you build your own form UI instead of the hosted page.

curl https://api.sendara.dev/v1/public/forms/k3f9a1b2c7d4
{
  "name": "Newsletter signup",
  "heading": "Join the list",
  "description": "",
  "fields": {
    "first_name": { "enabled": true, "required": false },
    "last_name":  { "enabled": false, "required": false },
    "phone":      { "enabled": false, "required": false }
  },
  "button_text": "Subscribe",
  "success_message": "Thanks for subscribing!",
  "double_opt_in": true
}

Submit the form

POST /v1/public/forms/{slug}/submitis the unauthenticated endpoint every form posts to. The hosted page and embed call it for you; if you render your own UI, call it directly. Send a JSON body with the subscriber's email and any enabled fields. It is IP rate-limited and honeypot-protected— bodies are capped at 16 KB.

Submit body

emailstringRequired
The subscriber's email. Normalized (trimmed + lowercased) and validated; junk returns invalid_email (400).
first_namestringOptional
Captured when the form enables the first_name field.
last_namestringOptional
Captured when the form enables the last_name field.
phonestringOptional
Captured when the form enables the phone field.
company_urlstringOptional
The honeypot. A hidden trap field humans never see — leave it empty. Any non-empty value silently drops the submission (see below).

What a submit does, in order: it upserts the contact by email (creating it, or updating the name and phone on an existing one), adds the contact to the form's target list, and records the submission. The response tells you which path you're on:

Submit responses
ResponseMeaning
{ "status": "subscribed" }Single opt-in. The contact's email_consent is set to subscribedimmediately — they're on the list and reachable by your next marketing send.
{ "status": "pending" }Double opt-in. The contact is stored with email_consent: "pending" and a confirmation email goes out. They become subscribed only after clicking the link.
A submit always returns 200 on success — there is no draft or queued state. The only difference double opt-in makes is whether the contact lands as subscribed or pending.

The honeypot

Every form ships with a hidden field named company_url — rendered off-screen, with tabindex="-1" and autocomplete="off", so a real person never sees or fills it. Bots that blindly complete every input will. If company_url arrives non-empty, the submit is treated as spam: it is silently dropped — no contact, no list add, no submission row — and still returns { "status": "subscribed" } so the bot can't tell a drop from a real signup.

If you build your own UI, keep a hidden, visually-removed company_urlinput and submit it empty. Don't label it in a way a password manager or autofill would target — the whole point is that only a bot touches it.

Double opt-in & confirmation

With double_opt_in: true, a submission doesn't subscribe anyone yet — it parks the contact at email_consent: "pending", records an unconfirmed submission, and emails a confirmation link. The link points at GET /v1/public/forms/confirm?token=…. When the subscriber clicks it, Sendara flips the contact to subscribed, marks the submission confirmed, and shows a friendly “You're subscribed” page.

# The link inside the confirmation email
GET https://api.sendara.dev/v1/public/forms/confirm?token=<signed-token>

The token is HMAC-signed, not a database lookup. It encodes the form id, the email, and an expiry, signed with HMAC-SHA256 — the same stateless pattern as one-click unsubscribe links. Sendara verifies the signature in constant time and checks the expiry before honoring it, so a tampered or forged token is rejected. Confirmation links are valid for 72 hours.

An invalid, tampered, or expired token renders a “Link expired” page rather than subscribing anyone. If a subscriber waits past 72 hours, they simply submit the form again to get a fresh link.
Double opt-in is the cleanest way to start a list: only confirmed, intentional subscribers reach subscribed, which keeps your engagement high and your sending reputation healthy. Single opt-in subscribes on submit — faster, but you own the consent quality.

A form writes to two places, and it's worth knowing which:

  • The contact is upserted by email and its email_consent is set — subscribed for single opt-in, pending then subscribed for double opt-in. This is the app-layer opt-in your marketing broadcasts honor.
  • The contact is added to the form's target list idempotently — re-submitting the same email never creates a duplicate membership.
Because a confirmed form signup can trigger an automation (the form_signup trigger), pointing a form at a list with a welcome sequence is a complete onboarding flow with no glue code.

Errors

Form endpoints use the standard error envelope. The codes you'll see most:

Form error codes
CodeStatusWhen it fires
invalid_request400Missing name or list_id, a non-http(s) redirect_url, or a statusthat isn't active / inactive.
invalid_list400The list_iddoesn't belong to your account.
invalid_email400A submit body had a missing or malformed email.
not_found404No form with that id for your account, or a public slug that is unknown or inactive.
unauthorized401Missing or invalid API key on a /v1/forms CRUD call. The public endpoints never require one.
forbidden403Key lacks the required scope — read to list/get, admin to create/update/delete.
The public submit endpoint hides drops behind a subscribedresponse on purpose — a non-empty honeypot or a suppressed-looking address doesn't surface an error to the visitor, so spammers learn nothing from the response.

Scopes

The public endpoints — GET /v1/public/forms/{slug}, POST /v1/public/forms/{slug}/submit, GET /v1/public/forms/confirm, and GET /v1/public/f/{slug} — take no API key; they are IP rate-limited and treat every input as hostile. The CRUD endpoints require a key: listing and reading a form need the read scope; creating, updating, and deleting need admin. See Authentication for how scopes work.