# Sendl API & MCP Reference

Sendl is a Typeform-style form builder. Everything you can do in the dashboard you can do
programmatically through the **REST API** or the **MCP server** (for Claude / ChatGPT and other agents).

- **Base URL:** `https://dash.sendl.io`
- **REST base:** `https://dash.sendl.io/api/v1`
- **MCP endpoint:** `https://dash.sendl.io/mcp?api_key=<your_key>`
- **Raw markdown of this page (for feeding to an LLM):** `https://dash.sendl.io/docs.md`

---

## Authentication

Every REST request authenticates with your API key (find/rotate it in the dashboard → Settings → API):

```
Authorization: Bearer <api_key>
# or
X-API-Key: <api_key>
```

**Workspaces.** If you belong to other workspaces (teams/agency), target one with a header or query param;
the default is your own workspace. Your role in that workspace governs what you can do.

```
X-Workspace: <workspace_id>          # or  ?workspace=<workspace_id>
```

**Roles:** viewer < editor < admin < owner. Reads need viewer; creating/editing forms needs editor;
team/email/settings need admin.

### Provisioning a key (fleet/partner apps — Devani, Tonta, Pinpic, AltText)

Fleet apps mint or fetch a Sendl key for a site by email, instead of a human opening Settings:

```
POST https://dash.sendl.io/api/devani-provision.php          (Content-Type: application/json)
  { "email": "site-owner@example.com", "site_url": "https://theirsite.com" }
→ 200  { "success": true, "api_key": "<sendl key for that email>" }
```

Find-or-create and idempotent: the same email always yields the same key. A new email creates a
fresh free-tier account; an existing email returns that account's key.

**SECURITY WARNING — known issue, fix pending.** This endpoint is **unauthenticated** and returns the
API key for *any* existing account by email (only IP-rate-limited, ~30/hr). The intended caller
(Devani) verifies the email via Tonta before calling, but Sendl does **not** enforce that — a direct
caller who knows an account's email gets its key, including an agency **master** key. Do not treat
this as a secure provisioning path. Planned hardening: a shared partner secret required to return an
existing key, plus only auto-returning keys for accounts this endpoint itself created. For
agency→client integrations, prefer the authenticated master-key flow below
(`GET /tenants` → use each tenant's scoped `api_key`).

---

## Plan access (important)

REST entitlement follows the **workspace's plan**:

- **Free:** read-only access to your own forms — enough to list them and fetch their public embed URLs.
  Allowed routes: `GET /me`, `GET /forms`, `GET /forms/{id}`, `GET /workspaces`. Everything else returns 403.
  (Free accounts also get the full MCP server — see below.)
- **Pro / Business / Agency:** full read **and** write REST access.

A blocked call returns `403` with a JSON `error` explaining what's needed.

---

## Quickstart

```bash
# List your forms (works on every plan)
curl -H "Authorization: Bearer $KEY" https://dash.sendl.io/api/v1/forms

# Fetch one form (includes its public embed URL)
curl -H "Authorization: Bearer $KEY" https://dash.sendl.io/api/v1/forms/123

# Create a form (Pro+)
curl -X POST https://dash.sendl.io/api/v1/forms \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"title":"Contact","status":"published","fields":[
        {"type":"short_text","title":"Your name","ref":"name","required":true},
        {"type":"email","title":"Email","ref":"email","required":true},
        {"type":"long_text","title":"Message","ref":"message","required":true}]}'
```

Responses are JSON. Forms are returned with a `url` field = the public form page (`https://dash.sendl.io/f/<slug>`),
which is iframe-embeddable as-is (no `X-Frame-Options`).

---

## REST API reference

All paths are relative to `https://dash.sendl.io/api/v1`.

### Account
```
GET    /me                              your account + active workspace + plan usage
PATCH  /me                              {name?, show_branding?} — show_branding toggles the "Made with
                                         Sendl" badge workspace-wide (white-label plans; admin role)
POST   /me/api-key                      rotate the API key (the old key dies immediately)
GET    /workspaces                      workspaces you belong to (id, name, role)
```

### Forms
```
GET    /forms                           list forms (+ response/view stats)
POST   /forms                           create — {title, description?, mode?, status?, fields?, theme?, settings?}
GET    /forms/{id}                       one form (fields, theme, settings, public url)
PATCH  /forms/{id}                       partial update — same body as create; also {slug} (change URL), {domain_id} (custom domain to serve on — see /domains)
DELETE /forms/{id}
POST   /forms/{id}/publish
POST   /forms/{id}/unpublish
POST   /forms/{id}/duplicate             copy as a new draft
```
`mode`: `typeform` (one question at a time) or `classic` (all on one page). `status`: `draft` | `published` | `closed`.

### Submissions
```
GET    /forms/{id}/submissions           ?status=complete|partial&limit=&offset=
POST   /forms/{id}/submissions           create — {answers: {field_id_or_question_title: value}}
GET    /forms/{id}/submissions/{sid}
DELETE /forms/{id}/submissions/{sid}
GET    /forms/{id}/submissions/export    CSV download of all responses
GET    /forms/{id}/submissions/{sid}/messages    two-way email thread for a response
POST   /forms/{id}/submissions/{sid}/reply       {body} — email the respondent; replies thread back
```
Each submission includes its **`id`** (use it for `GET`/`DELETE /forms/{id}/submissions/{sid}`), plus `answers` and `answers_readable` (question text + answer per field). `list_submissions` is how you discover submission IDs; the CSV export's first column is the Submission ID too.

### Analytics
```
GET    /forms/{id}/analytics             views, starts, completions, completion rate, per-question stats
```

### Templates
```
GET    /templates                        prebuilt starter templates
POST   /forms/from-template/{slug}       {title?} — create a form from a template
```
AI form-building is MCP-native: connect Claude or ChatGPT (see [MCP server](#mcp-server-claude-chatgpt-agents))
and ask for the form you want — the assistant designs it and creates it with `create_form` directly.

### Webhooks
```
GET    /webhooks
POST   /webhooks                         {url, kind?: generic|slack|teams, form_id?, events?: [submission.completed, submission.partial, form.published, submission.paid]}
                                         kind "slack"/"teams" posts a formatted message to a channel incoming-webhook URL (no signature); "generic" = signed JSON.
PATCH  /webhooks/{id}                    {url?, active?, events?}
DELETE /webhooks/{id}
GET    /webhooks/{id}/deliveries
GET    /forms/{id}/deliveries            recent deliveries for one form
```
Each delivery is signed: `X-Forms-Signature: sha256=HMAC_SHA256(raw_body, webhook.secret)`. Failed deliveries retry for ~4 hours.

### Contacts (CRM)
Every completed submission with an email (or phone) field automatically upserts a **contact** —
one deduped person per workspace, with a running activity timeline. You can also add people
directly or import a list.
```
GET    /contacts                         ?q= searches email/name/phone; ?limit= & ?offset=
POST   /contacts                         {email | phone, name?, tags?} — manual add (deduped by email)
POST   /contacts/import                  {contacts: [{email, name?, phone?}], tags?} — bulk, max 5000/call
GET    /contacts/{id}                    contact + timeline (submissions, automation touches, when)
PATCH  /contacts/{id}                    {tags?, name?, phone?, unsubscribed?} — tags replaces the list;
                                          unsubscribed=true suppresses all automation emails to them
```
Contacts carry `submission_count`, `first_seen`, `last_seen`, `tags`, `unsubscribed`. Newly **added**
tags (manual, import, API, or a tag step) enroll the contact in matching tag-triggered automations.
Automation emails to a person carry a signed one-click unsubscribe link; unsubscribed contacts are
skipped by sequences and automation emails automatically.

### Automations
Event-triggered workflows: when a form is submitted (or paid), run ordered steps — send a
transactional email, tag the contact, POST a webhook, or wait and continue later. Steps run in the
background worker, never on the respondent's request.
```
GET    /automations
POST   /automations                      {name, trigger_event?, form_id?, conditions?, steps, active?}
GET    /automations/{id}
PATCH  /automations/{id}                 partial update; {active: false} pauses
DELETE /automations/{id}
GET    /automations/{id}/runs            recent runs with per-step logs
```
- `trigger_event` + `trigger_config`:
  - `submission.completed` (default) / `submission.paid` — fire on finish / on payment.
  - `submission.abandoned` — the respondent started but never finished. Runs `trigger_config.abandon_minutes`
    (default 60, max 7 days) after the partial save, and is skipped automatically if they completed by then.
    Needs autosave enabled on the form. The classic lead-recovery play.
  - `contact.tagged` — a **sequence**: starts when `trigger_config.tag` is added to a contact (manually, on
    import, via the API, or by another automation's tag step). Runs **once per contact**, skips unsubscribed
    contacts, and ignores `form_id`/`conditions`.
- `form_id` omitted/0 = every form in the workspace.
- `conditions` (optional, submission triggers): `[{ref, op, value}]`, all must match. `op`: `equals | not_equals | contains | not_empty`; `ref` is the answer key (@ref) of a field.
- `steps` (ordered, max 20):
  - `{type: "email", to: "respondent"|"team", address?, subject, body}` — transactional only. Subject/body pipe answers with `@ref` or `{{field:Question title}}` (in sequences: `{{name}}` / `{{email}}`). **Respondent** emails send via the workspace's own configured provider (Settings → Email), are skipped without one, auto-append a one-click **unsubscribe link**, and honor prior unsubscribes. **Team** emails (`address`) deliver only to a **verified notification address**.
  - `{type: "tag", tags: ["lead"]}` — tags the contact; newly added tags can chain into tag-triggered sequences.
  - `{type: "webhook", url, secret?}` — POSTs `{event: "automation.step", automation, data}` (submission payload, or `{contact}` in sequences) to an https URL; with `secret`, signed `X-Sendl-Signature: sha256=HMAC_SHA256(raw_body, secret)`.
  - `{type: "wait", amount, unit: "minutes"|"hours"|"days"}` — pause up to 30 days, then continue.
  - `{type: "mesita_card", api_key, list_id, title?, description?}` — create a card on a [Mesita](https://dash.mesita.io) board list (your Mesita API key; find list ids via Mesita's API/MCP). Blank title/description default to the respondent + all answers. Form → kanban pipeline in one step.
  - `{type: "openlagoon_booking", slug, when: "next_available"|"ref", start_ref?}` — book the respondent onto an [OpenLagoon](https://dash.openlagoon.com) booking link (their name + email from the submission/contact). `when: "ref"` reads a UTC `YYYY-MM-DD HH:MM` time from the answer with that @ref. Public, free meeting types only — paid or verification-gated links can't be auto-booked.

Example — tag new leads and follow up the next day:
```json
POST /automations
{
  "name": "Lead follow-up",
  "form_id": 12,
  "conditions": [{"ref": "interested", "op": "equals", "value": "yes"}],
  "steps": [
    {"type": "tag", "tags": ["lead"]},
    {"type": "wait", "amount": 1, "unit": "days"},
    {"type": "email", "to": "respondent", "subject": "Following up, @name",
     "body": "Hi @name — thanks for your interest. Want to pick a time?"}
  ]
}
```

### Conversion tracking (server-side pixels) — Pro+
Fire a conversion **server-side** to ad platforms when a form is submitted, so conversions survive
iOS-14 signal loss and ad-blockers. Per-form, per-provider; access tokens are write-only (never returned).
```
GET    /forms/{id}/conversions                  list targets (masked) + recent fire log
PUT    /forms/{id}/conversions/{provider}        {pixel_id, access_token, event_name?, test_event_code?, enabled}
DELETE /forms/{id}/conversions/{provider}
POST   /forms/{id}/conversions/{provider}/test   fire a test event; returns the platform's HTTP response
```
`provider`: `meta` (live), `ga4` / `tiktok` (coming). `access_token` is write-only — omit it on PUT to keep the saved one.

**The dedup model (read this — double-counting is worse than missing).** On every submission Sendl generates one
`event_id` (the submission token) and uses it in **both** places:
- the **server** CAPI call (the source of truth — runs even if the browser pixel is blocked), and
- a **browser pixel** Sendl drops on the thank-you screen, firing the same event with the same `event_id`.

Meta de-duplicates the two by `event_id` + `event_name`, so a conversion counts **once** whether the browser
pixel ran or not. You don't manage the `event_id` — Sendl handles it. Just set the same pixel/event on both
sides (Sendl does that automatically). User data (email, phone) is SHA-256 hashed before sending; IP/UA and
`_fbp`/`_fbc` cookies are passed per Meta's spec; UTM/click-id hidden fields pass through as custom data. Set a
**Test Event Code** (Meta Events Manager → Test Events) before using the test button so test fires don't count.

### Email settings & sending domains
```
GET    /settings/email                   provider config (provider, from-address, integrations)
PATCH  /settings/email                   partial — {provider: none|smtp|postmark, postmark_token, smtp_host,
                                          smtp_port, smtp_user, smtp_pass, smtp_secure: tls|ssl|none,
                                          from_email, from_name, reply_to}
POST   /settings/email/test              {to?} — send a test email
GET    /email-log                        ?limit= — sent/failed history with errors

# Extra sending identities (Agency only) — isolate each domain's reputation behind its own Postmark/SMTP:
GET    /settings/senders
POST   /settings/senders                 {label, provider, postmark_token | smtp_*, from_email, from_name, reply_to}
GET    /settings/senders/{id}
PATCH  /settings/senders/{id}            partial
DELETE /settings/senders/{id}

# Admin-notification recipients — a form's notifications.owner_email must be a VERIFIED address here:
GET    /notify-addresses                 list addresses + verified status + limit
POST   /notify-addresses                 {email} — emails a verification link; usable once clicked   [admin]
DELETE /notify-addresses/{id}            remove an address
```
Tokens/passwords are write-only and never returned. A form routes through a sender via `settings.notifications.sender_id`.

Admin (owner) notifications only reach a **verified** address — your login email is always verified; add others via `POST /notify-addresses` (it emails a verify link). Limits: Free 1, Pro/Business 5, **Agency unlimited** — every address must be verified. No own provider? Sendl relays admin notifications to those verified addresses; respondent confirmations always require your own Postmark/SMTP.

### Team
```
GET    /team                             members + seats_used/seats_limit
POST   /team                             {email, role: admin|editor|viewer} — invite (claimed at login)
PATCH  /team/{id}                        {role}
DELETE /team/{id}
```

### Agency / tenants (master + sub-account keys)

An **agency** account is a master account that owns isolated client **sub-accounts** ("tenants").
Each sub-account is its own workspace with its **own API key**, scoped to only that client's forms
and submissions — one client's key can never see another client's data. This is how you give each
client (or each site you build) its own isolated key.

```
GET    /tenants                          list tenants + each one's scoped api_key, usage, domain   [Agency]
POST   /tenants                          {name, email?} — create a sub-account; returns {id, name, api_key}
PATCH  /tenants/{id}                      {name?}
DELETE /tenants/{id}                      deletes the tenant + all its forms/responses
```

How the two key levels work:

- **Master (agency) key** — controls the agency's own forms, creates sub-accounts, and can act
  inside any sub-account by adding the header `X-Workspace: <tenant_id>` to any request.
- **Sub-account key** — returned in the `api_key` of `POST /tenants` (and `GET /tenants`). Use it as
  an ordinary `Authorization: Bearer <key>`; every endpoint is automatically limited to that client.

**Provisioning a site (one call):** `POST /tenants {name, email}` with the master key returns the new
tenant's scoped `api_key` directly — store that and use it for all of the site's calls. No follow-up
lookup needed. (`GET /tenants` re-lists keys any time.) Sub-account keys are **write-capable**.

**Tenants inherit the agency's plan** — a tenant under an Agency account gets **Agency-tier
entitlements**: full read+write API, **unlimited verified notification recipients**, and **multiple
sending identities** (`/settings/senders`). So a provisioned client site has the same capabilities
as the agency, scoped to its own data.

### Custom domains
Serve forms on your own domains. Domains are managed per account/workspace; each **form** opts into
one domain — a domain serves **only** the forms that chose it (everything else 404s), and forms always
stay reachable at `https://dash.sendl.io/f/<slug>` too. The dashboard, login and API are never exposed on a custom
domain. Agencies can mark a domain **shared** so their tenants may use it; otherwise a domain is scoped
to the account that added it.

```
GET    /domains                          list this account's usable domains + default_domain_id
POST   /domains                          {domain, shared?} — add + auto-provision HTTPS   [admin]
PATCH  /domains                          {default_domain_id} — set the account's default domain
DELETE /domains/{id}                     remove a domain (forms on it revert to the default)
```

Provisioning is automatic: add the domain, point its CNAME at `dash.sendl.io`, and Sendl issues the
Let's Encrypt cert and serves it (a reconciler retries until DNS lands — no re-submit needed). A
domain's `status` is `pending_dns` → `active` (or `failed`). Assign a form to a domain with the form's
**`domain_id`** (see below; `PATCH /forms/{id} {domain_id}` or the builder's Share tab). A form with no
`domain_id` uses its account's `default_domain_id`, if set. For per-tenant domains, manage them with the
tenant's own key (or `X-Workspace: <tenant_id>`).

### Images
```
POST   /images/import                    {url | base64, filename?} — host an image on the CDN (Tonta);
                                          returns a permanent URL for media / option images / og:image / logo
```

---

## Form field schema

A form's `fields` is an ordered array. Each field:

```jsonc
{
  "type": "short_text",
  "title": "What is your name?",
  "description": "optional helper text",
  "required": true,
  "placeholder": "",
  "ref": "name",                 // answer key: pipe with @name, pre-fill via /f/slug?name=value
  "options": [{"label": "A", "points": 0}],   // dropdown / multiple_choice / checkboxes / picture_choice / product / ranking
  "settings": {
    "scale": 5,                  // rating
    "min": 0, "max": 10,         // opinion_scale
    "max_length": 500,           // text
    "points_yes": 1, "points_no": 0,   // yes_no (quiz scoring)
    "default": "",               // hidden: value when the URL param is missing
    "skip_if_prefilled": true,   // skip the question if pre-filled from the URL
    "multiple": false,           // picture_choice / product
    "currency": "usd",           // product: usd eur gbp sek nok dkk cad aud
    "accept": "image"            // file_upload: filter the picker to images
  },
  "media": {"url": "...", "type": "image|video", "layout": "left|right|background", "dim": 40},
  "logic": [{"if": {"op": "eq|neq|gt|lt|contains|answered|not_answered", "value": "x"}, "goto": "field_id|_end"}],
  "show_if": {"field": "field_id", "op": "eq", "value": "x"},
  "remote_validation": {"enabled": false, "url": "https://your-endpoint", "required": false, "timeout_ms": 3000, "secret": ""}
}
```

**Field types:** `short_text` `long_text` `email` `phone` `number` `url` `date` `dropdown`
`multiple_choice` `checkboxes` `yes_no` `rating` `opinion_scale` `statement` `file_upload`
`legal` `hidden` `picture_choice` `ranking` `product`.

- `file_upload` uploads to the Tonta CDN automatically; set `settings.accept: "image"` for an image upload.
- `picture_choice`/`product` options take `{label, image?, price? (product), points?}`.
- **Piping:** write `@ref` in any later question title/description, statement, thank-you message, or email
  subject/body — it renders that answer (typed or from a `?ref=value` URL param).
- **Hidden fields** are invisible; captured from `/f/slug?ref=value` (or `settings.default`).
- **Remote validation:** `remote_validation` calls your https endpoint at submit time to accept/reject the answer (uniqueness/existence). Set via API/MCP like any field property — full request/response schema, fail-open behavior, HMAC and starter endpoints in [Remote validation](https://dash.sendl.io/docs#remote-validation).

## Form settings object

```jsonc
"settings": {
  "welcome":  {"enabled": false, "title": "", "description": "", "button_text": "Start"},
  "thankyou": {"message": "Thanks!", "redirect_url": "", "show_score": false},
  "notifications": {
    "owner_enabled": true, "owner_email": "you@example.com",  // must be a VERIFIED notification address (Settings)
    "respondent_enabled": true, "respondent_email_field": "<email field id>",  // respondent confirmations require your own email provider
    "respondent_subject": "We received your message",
    "respondent_body": "Hi @name, ...",
    "respondent_html": true,        // render the body as HTML (with a plain-text fallback)
    "sender_id": 0,                 // route through an added sending identity (0 = workspace default)
    "from_email": "", "from_name": "", "reply_to": ""
  },
  // Admin notifications only send to a VERIFIED address (your login email, plus verified extras —
  // 1 on Free, 5 on Pro/Business, unlimited on Agency; every extra address must be verified).
  // No provider configured? Sendl relays admin notifications to those verified addresses;
  // respondent confirmation emails always require your own Postmark/SMTP.
  "scoring": {"enabled": false, "ranges": []},
  "autosave": true,
  "limit_responses": 0,
  "close_at": "",                   // ISO datetime; form closes after this
  "language": "en",                 // en es fr de pt it nl sv
  "security": {"turnstile": false, "one_response": "off|cookie|ip",
               "one_response_max": 1, "one_response_days": 0}, // max responses per person per N days (0 = ever)
  "widget": {"enabled": false, "position": "lower-right|lower-left|upper-right|upper-left",
             "accent": "", "size": 64, "content": "icon|image|video", "media_url": "",
             "greeting": "", "greeting_delay": 4, "auto_open": 0, "label": ""}, // floating popup launcher; embed via /assets/widget.js
  "endings": [{"message": "", "button_text": "", "button_url": "", "redirect_url": "",
               "show_score": false, "conditions": [{"field": "", "op": "", "value": ""}],
               "score_min": null, "score_max": null}],
  "seo": {"og_image": ""}
}
```

**Payments:** add a `product` field + a Stripe key in Settings. Product selections create a Stripe Checkout
session on submit; the `submission.paid` webhook fires after payment and the submission gains a `payment` block.

---

## Errors

JSON body `{"error": "..."}` (or `{"errors": {field_id: "..."}}` on validation) with an HTTP status:

```
400 bad request / invalid body      403 plan or role not permitted
401 missing/invalid API key          404 not found
409 conflict (e.g. slug/domain)      422 validation failed
429 rate limited                     200/201 success
```

---

## MCP server (Claude, ChatGPT, agents)

The MCP server mirrors the REST API 1:1 as tools. **The full MCP is included on every plan, including Free**
(Free is limited to the 10 most recent submissions, and CSV export / webhooks are Pro).

**Connect URL** (key is in the URL — paste it straight in):
```
https://dash.sendl.io/mcp?api_key=<your_key>
```

- **claude.ai:** Settings → Connectors → Add custom connector → paste the URL.
- **ChatGPT:** Settings → Connectors (Developer Mode) → Add → MCP server, **No Authentication** → paste the URL.
- **Claude Code:** `claude mcp add sendl --transport http "https://dash.sendl.io/mcp?api_key=<your_key>"`

Call **`get_form_building_guide`** once before building forms — it returns the full field schema, answer-key
piping, logic, quiz scoring and theme options. Pass `workspace_id` on any tool to act in another workspace.

**Tools:**

- *Account/teams:* `get_me`, `list_workspaces`, `update_profile`, `list_team`, `invite_member`, `set_member_role`, `remove_member`
- *Agency:* `list_tenants`, `provision_tenant`, `update_tenant`, `delete_tenant`
- *Forms:* `list_forms`, `get_form`, `create_form`, `update_form`, `delete_form`, `publish_form`, `unpublish_form`, `duplicate_form`, `get_form_building_guide`
- *Templates:* `list_templates`, `create_form_from_template`
- *Submissions:* `list_submissions`, `get_submission`, `create_submission`, `delete_submission`, `export_submissions_csv`, `get_form_analytics`, `list_messages`, `send_reply`
- *Contacts (CRM):* `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `import_contacts`
- *Automations:* `list_automations`, `get_automation`, `create_automation`, `update_automation`, `delete_automation`, `list_automation_runs`
- *Custom domains:* `list_domains`, `add_domain`, `set_default_domain`, `delete_domain`
- *Webhooks:* `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook`, `list_webhook_deliveries`
- *Conversion tracking (Pro+):* `list_conversion_targets`, `set_conversion_target`, `delete_conversion_target`, `test_conversion_target`
- *Email / sending domains:* `get_email_settings`, `update_email_settings`, `send_test_email`, `get_email_log`, `list_notify_addresses`, `add_notify_address`, `delete_notify_address`, `list_senders`, `create_sender`, `update_sender`, `delete_sender`
- *Media:* `import_image`

**Pro-only tools:** `export_submissions_csv`, `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook`, `list_webhook_deliveries`.

---

## Embedding a form

Published forms serve at `https://dash.sendl.io/f/<slug>` — a standalone page with no dashboard chrome and no
`X-Frame-Options`, so it drops straight into an iframe:

Simple fixed-height embed:
```html
<iframe src="https://dash.sendl.io/f/your-slug" width="100%" height="600" frameborder="0"></iframe>
```

**Auto-resizing embed (recommended)** — the form posts its real height to the parent via
`postMessage`, so the iframe fits its content (ideal for one-page/classic forms, and it follows
multi-step forms as the question changes):
```html
<iframe id="sendl-your-slug" src="https://dash.sendl.io/f/your-slug" style="width:100%;border:none;min-height:240px" scrolling="no" loading="lazy"></iframe>
<script>
addEventListener("message",function(e){
  if(e.origin!=="https://dash.sendl.io"||!e.data||e.data.type!=="sendl:resize"||e.data.slug!=="your-slug")return;
  document.getElementById("sendl-your-slug").style.height=e.data.height+"px";
});
</script>
```
The form emits `{type:"sendl:resize", slug, height}` on load and whenever its content height changes.
The dashboard's Share tab generates this snippet for you with the slug filled in.

`GET /forms` and `GET /forms/{id}` return the public URL in the `url` field — so a free account can fetch
its forms and embed them anywhere.

---

## Remote validation

Validate a field's answer against **your own endpoint** at submit time — e.g. enforce uniqueness
("this keyword is already taken") or an existence check. It's **opt-in per field** in the builder
(select a question → **Remote validation**). It adds a network round-trip on submit, so turn it on
only for the fields that genuinely need it, not every field.

**How it runs:** after a submission passes local validation and **before** it is saved, Sendl POSTs
to your endpoint. If your endpoint reports the value invalid, the submission is **rejected and not
stored**, and your message renders inline on that field. Multiple flagged fields are validated
**concurrently**. The endpoint must be **https** (private/internal hosts are blocked).

### Request Sendl sends
```
POST <your endpoint>   (application/json)
X-Sendl-Signature: <hex>      # only when a signing secret is set (see HMAC below)

{
  "form_id": "...",
  "field_id": "...",
  "field_label": "Keyword",
  "value": "the submitted value",
  "submission_id": "..."
}
```

### Response Sendl expects (2xx, JSON)
```
{ "valid": true }

{ "valid": false, "message": "Keyword already taken — pick another." }
```
Only `valid` (boolean) is required. `message` is optional — shown inline when `valid` is false; omit
it and Sendl falls back to a generic "This value isn't allowed." Only a 2xx with a well-formed
`{ "valid": ... }` body is treated as authoritative.

### Timeout & fail-open vs fail-closed
Each field has a timeout (default 3000ms, max 8000) and a switch for what happens when your endpoint
times out, errors, or returns something unparseable:
- **Allow submission (fail-open)** — the answer passes; every bypass is logged server-side.
- **Block submission (fail-closed)** — the submission is blocked with a "couldn't verify, try again".

### HMAC verification
Set a signing secret on the field and Sendl adds `X-Sendl-Signature` = hex **HMAC-SHA256 of the raw
request body** using that secret. Recompute it over the raw body and compare before trusting the call.

### Starter endpoint — Cloudflare Worker
```
export default {
  async fetch(req) {
    const raw = await req.text();
    // const ok = await verify(SECRET, raw, req.headers.get('X-Sendl-Signature'));
    const { value } = JSON.parse(raw);
    const taken = await isTaken(value);                 // <-- your check
    return Response.json(taken
      ? { valid: false, message: 'Already taken — pick another.' }
      : { valid: true });
  }
}

async function verify(secret, body, sigHex) {
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
  const hex = [...new Uint8Array(mac)].map(b => b.toString(16).padStart(2, '0')).join('');
  return hex === sigHex;
}
```

### Starter endpoint — Express
```
const express = require('express');
const crypto  = require('crypto');
const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

app.post('/validate', (req, res) => {
  const secret = process.env.SENDL_SECRET;
  if (secret) {
    const mac = crypto.createHmac('sha256', secret).update(req.rawBody).digest('hex');
    if (req.get('X-Sendl-Signature') !== mac) return res.status(401).json({ valid: false, message: 'bad signature' });
  }
  const taken = checkTaken(req.body.value);             // <-- your check
  res.json(taken
    ? { valid: false, message: 'Already taken — pick another.' }
    : { valid: true });
});

app.listen(3000);
```

Use the **Test endpoint** button in the field's Remote-validation config to fire a sample request and
see the raw response plus whether it parsed into a valid envelope.

---

## Plans

| | Free | Pro $19 | Business $49 | Agency $149 |
|---|---|---|---|---|
| Submissions / mo | 100 | 25,000 | 100,000 | 500,000 |
| Storage | 5 GB | 50 GB | 250 GB | 1 TB |
| Seats | 1 | 3 | 10 | 25 |
| REST API | read-only | read & write | read & write | read & write |
| MCP | full (10 recent subs) | full | full | full |
| Webhooks / CSV export | — | yes | yes | yes |
| Payments (Stripe) | — | yes | yes | yes |
| White-label | — | — | yes | yes |
| Tenants / sending domains | — | — | — | yes |

See `https://dash.sendl.io/pricing`.