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.
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.
| Surface | What it is |
|---|---|
| Hosted page | A standalone, branded page at /v1/public/f/{slug}. Share the link anywhere — it renders the full form and handles the submit. |
| Embedded widget | The 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 config | A 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
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"
}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"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
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:
| Response | Meaning |
|---|---|
| { "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. |
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.
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.
subscribed, which keeps your engagement high and your sending reputation healthy. Single opt-in subscribes on submit — faster, but you own the consent quality.What lands on the list
A form writes to two places, and it's worth knowing which:
- The contact is upserted by email and its
email_consentis set —subscribedfor single opt-in,pendingthensubscribedfor 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.
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:
| Code | Status | When it fires |
|---|---|---|
| invalid_request | 400 | Missing name or list_id, a non-http(s) redirect_url, or a statusthat isn't active / inactive. |
| invalid_list | 400 | The list_iddoesn't belong to your account. |
| invalid_email | 400 | A submit body had a missing or malformed email. |
| not_found | 404 | No form with that id for your account, or a public slug that is unknown or inactive. |
| unauthorized | 401 | Missing or invalid API key on a /v1/forms CRUD call. The public endpoints never require one. |
| forbidden | 403 | Key lacks the required scope — read to list/get, admin to create/update/delete. |
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.