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 create a Sendl account for a new 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": "<key for the new account>" }
→ 409  { "success": false, "code": "account_exists", "error": "..." }

Only callable from the fleet servers that run the Devani setup. A new email creates a fresh
free-tier account and returns its key. An email that already has an account gets 409 and never its
key: the account owner signs in and pastes their own key from Settings. For agency→client
integrations, use the authenticated master-key flow below (GET /tenants → 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://form.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.
Opt-out. Every email template purpose and every automation email step can use {{optout.url}} (a
signed link) or {{optout.button}} (the same link as a button). The button's wording and look are set
on the shortcode: `{{optout.button text="Stop these emails" bg="#1c1c1e" color="#ffffff" radius="4px"
size="14px" padding="11px 18px" align="left" border="none" width=""}}`. A person who confirms on that
page is marked unsubscribed, and from then on no automation of any kind runs for them: they are
never enrolled again and any run already in progress stops at its next step (emails, assessment and
interview invitations, reminders, tags, webhooks, board cards). Automation emails that do not place
the shortcode get a small "Stop automated emails" footer link appended.

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.

With your own provider configured, admin (owner) notifications send to any address — no verification needed. Without one, Sendl relays them via its shared sender and they 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. 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://form.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:

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

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",  // any address with an own provider; shared sender requires a VERIFIED address (Settings)
    "via_shared": false,            // force owner notifications through Sendl's shared sender even with an own provider (verified address required)
    "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": ""
  },
  // Own provider configured? Admin notifications send to ANY address. Without one, Sendl
  // relays them and only VERIFIED addresses receive mail (your login email, plus verified
  // extras — 1 on Free, 5 on Pro/Business, unlimited on Agency).
  // 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://form.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://form.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://form.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 export—yesyesyes
Payments (Stripe)—yesyesyes
White-label——yesyes
Tenants / sending domains———yes

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