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).
https://dash.sendl.iohttps://dash.sendl.io/api/v1https://dash.sendl.io/mcp?api_key=<your_key>https://dash.sendl.io/docs.mdEvery 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.
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).
REST entitlement follows the workspace's plan:
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.)
A blocked call returns 403 with a JSON error explaining what's needed.
# 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).
All paths are relative to https://dash.sendl.io/api/v1.
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)
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.
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.
GET /forms/{id}/analytics views, starts, completions, completion rate, per-question stats
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.
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.
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.
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 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 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:
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?"}
]
}
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 oneevent_id (the submission token) and uses it in both places:
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.
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.
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}
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:
inside any sub-account by adding the header X-Workspace: <tenant_id> to any request.
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.
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'sdomain_id (see below; PATCH /forms/{id} {domain_id} or the builder's Share tab). A form with nodomain_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>).
POST /images/import {url | base64, filename?} — host an image on the CDN (Tonta);
returns a permanent URL for media / option images / og:image / logo
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 dropdownmultiple_choice checkboxes yes_no rating opinion_scale statement file_uploadlegal 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?}.@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).
/f/slug?ref=value (or settings.default).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."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.
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
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 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:
get_me, list_workspaces, update_profile, list_team, invite_member, set_member_role, remove_memberlist_tenants, provision_tenant, update_tenant, delete_tenantlist_forms, get_form, create_form, update_form, delete_form, publish_form, unpublish_form, duplicate_form, get_form_building_guidelist_templates, create_form_from_templatelist_submissions, get_submission, create_submission, delete_submission, export_submissions_csv, get_form_analytics, list_messages, send_replylist_contacts, get_contact, create_contact, update_contact, import_contactslist_automations, get_automation, create_automation, update_automation, delete_automation, list_automation_runslist_domains, add_domain, set_default_domain, delete_domainlist_webhooks, create_webhook, update_webhook, delete_webhook, list_webhook_deliverieslist_conversion_targets, set_conversion_target, delete_conversion_target, test_conversion_targetget_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_senderimport_imagePro-only tools: export_submissions_csv, list_webhooks, create_webhook, update_webhook, delete_webhook, list_webhook_deliveries.
Published forms serve at https://dash.sendl.io/f/<slug> — a standalone page with no dashboard chrome and noX-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 viapostMessage, 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.
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).
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": "..."
}
{ "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.
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:
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.
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;
}
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.
| 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.