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.io (dashboard + API)https://form.sendl.io/f/<slug> — forms are served on their own host. Old https://dash.sendl.io/f/<slug> links keep working: they return a permanent 301 to the same slug on https://form.sendl.io.https://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 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 scopedapi_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://form.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.
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.
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) send to any address with your own provider; on Sendl's shared sender they 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.
wait may include schedule: {mode: "delayed", amount, unit, tz, window: {enabled, days, start, end}, randomize: {enabled, minutes}}. Assessment and interview reminders may instead use schedule: {mode: "before_expiry", amount, unit} to send exactly that long before the candidate's private link expires (no window or randomisation; skipped once they complete or the link is gone). Randomization picks one whole-minute offset between minus and plus minutes after calculating the delay and allowed send time; for example, five minutes around 09:00 means 08:55–09:05. The selected time is saved on the run, so retries do not move it.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.
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.
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://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'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 group.
group): one respondent submits many entries of the same shape in a single submission (e.g. "add each SOP"). Give the field a fields array of child fields — one level deep,
any type except group/hidden/product, no child logic — plus `settings.{min_entries, max_entries,
add_button_label, entry_label} (entry_label = a child ref used to title each card). required` on
the group means at least one entry; required on a child applies per entry. One group per form.
Answers store as an array of objects keyed by child ref: "f_grp": [{"sop_name": "Onboarding"}, …].
Webhook payloads for group forms carry "version": 2 and answers_readable gains an entries key;
CSV export emits one row per entry (submission columns repeated, plus an Entry # column).
Inside a child title, @childref pipes the same entry's value; elsewhere @groupref gives the
joined entry labels.
file_upload uploads to the Tonta CDN automatically; set settings.accept: "image" for an image upload, or settings.accept: "video" for a video/screencast upload (MP4/MOV/WebM/M4V, 100 MB limit vs the standard 20 MB).
multiple_choice/checkboxes lists with 8+ options automatically show a type-to-filter search box (Enter picks the first match); dropdown is always searchable.
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", // 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.
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://form.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://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 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://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.
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.