Sendl Raw markdown ↓

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


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:

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


Quickstart

# 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

Example — tag new leads and follow up the next day:

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:

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:

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_dnsactive (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:

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

Form settings object

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

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:

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:

<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):

<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:

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

FreePro $19Business $49Agency $149
Submissions / mo10025,000100,000500,000
Storage5 GB50 GB250 GB1 TB
Seats131025
REST APIread-onlyread & writeread & writeread & write
MCPfull (10 recent subs)fullfullfull
Webhooks / CSV exportyesyesyes
Payments (Stripe)yesyesyes
White-labelyesyes
Tenants / sending domainsyes

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