REST API reference
Use the Smailor API to provision addresses, send email from verified domains,
submit contact forms into a shared inbox, and inspect domain reputation. This
reference separates API-key endpoints from browser-session endpoints so you can
choose the correct authentication model.
Messages sent through the API count toward the authenticated account's plan and
safety limits.
Before you start
You need:
- a Smailor account with API access;
- an API key created in Settings → API;
- a verified custom domain for mailbox provisioning and outbound sending;
- a server-side environment variable or secret manager for the API key;
- a recipient address you control for the first delivery test.
Never embed a Smailor API key in browser JavaScript, a mobile application bundle,
a public repository, or a support screenshot. The key grants account-level
access and is shown in full only when it is created.
Request flow
Your backend
|
+--> Authorization: Bearer
|
v
Smailor API --> validate account, domain, limits, and reputation
|
+--> 2xx: request accepted
+--> 4xx: change the request or account configuration
`--> 5xx: temporary failure; retry conservativelyBase URL
Use the origin of the Smailor instance:
| Environment | Base URL |
|---|---|
| Production | https://smailor.com |
| Local development | http://localhost:5173 |
Examples below use production. Do not send production credentials to a local or
untrusted origin.
Authentication
Send one of these headers on every API-key request:
Authorization: Bearer YOUR_API_KEYor:
X-API-Key: YOUR_API_KEYPrefer the standard Authorization header unless an intermediary strips it.
Treat a 401 unauthorized response as a key/configuration problem; creating
automatic retries will not fix an invalid credential.
Scopes
A key carries a set of scopes and each endpoint asserts the one it needs. Grant
the narrowest set that works: a key that only reads domains cannot be turned into
a key that sends if it leaks.
| Scope | Grants |
|---|---|
mail:send |
POST /api/v1/send |
mailboxes:write |
POST /api/v1/mailboxes |
domains:read |
GET /api/v1/domains |
focus:read |
the three Focus endpoints below |
mailboxes:read, tickets:read, tickets:write, focus:write |
Part of the vocabulary; no endpoint asserts them yet. A key may hold them and nothing changes today. |
Scopes are per resource and verb class rather than per endpoint, so an endpoint
added to a resource you already granted never invalidates a deployed key.
A request whose key lacks the scope is refused with 403 insufficient_scope, and
the body names both what was required and what the key holds:
{
"error": "insufficient_scope",
"message": "This API key is missing the \"focus:read\" scope.",
"requiredScope": "focus:read",
"grantedScopes": ["domains:read"]
}Retrying will not fix it — issue a key with the scope. Keys created before scopes
existed carry none in storage and are treated as unrestricted; re-issue them
rather than relying on that.
smtp:send is deliberately not in this vocabulary: it belongs to an SMTP
credential typed into a mail client and cannot be granted to an HTTP key. Same
for pop3:read and imap:read. Those are issued in the dashboard under
Settings → Mail client access, and are never interchangeable with a key.
Recommended integration flow
- Add and verify a custom domain.
- Call
GET /api/v1/domainsand require the expected domain to be verified. - Create member addresses with
POST /api/v1/mailboxeswhen needed. - Send a single controlled message with
POST /api/v1/send. - Confirm receipt and authentication at the destination mailbox.
- Handle documented
4xxresponses explicitly and retry only temporary
failures. - Monitor bounce and complaint trends before increasing volume.
Endpoint: List Domains + Reputation
GET /api/v1/domains
Returns all custom domains in your account with:
- Verification state (
verified) - 30-day sends, bounces, complaints
- Reputation status (
healthy,watch,blocked) - Recommendation message
Example
curl "https://smailor.com/api/v1/domains" \
-H "Authorization: Bearer sk_smailor_..."Endpoint: Create Mailbox (White-Label)
POST /api/v1/mailboxes
Content-Type: application/json
JSON body
| Field | Type | Required | Notes |
|---|---|---|---|
localPart |
string | yes | Mailbox local part, example john-doe |
domain |
string | yes | Must be a verified custom domain in your account |
displayName |
string | no | Sender display name |
Example
curl -X POST "https://smailor.com/api/v1/mailboxes" \
-H "Authorization: Bearer sk_smailor_..." \
-H "Content-Type: application/json" \
-d '{
"localPart": "john-doe",
"domain": "mailer.yourhost.com",
"displayName": "John Doe"
}'Reputation Guard
Mailbox creation is automatically blocked when domain reputation is too risky:
blockedstatus => API returns409 domain_reputation_blocked- Prevents large-scale onboarding on unhealthy domains
- Helps protect IP/domain reputation for all tenants
Endpoint: Send Email
POST /api/v1/send
Content-Type: application/json
JSON body
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | yes | Recipient email |
from |
string | yes | Must be an active address on a verified custom domain |
subject |
string | yes | Subject line |
text |
string | one of text/html |
Plain text body |
html |
string | one of text/html |
HTML body (sanitized server-side) |
headers |
object | no | List-Unsubscribe and List-Unsubscribe-Post together for RFC 8058 one-click unsubscribe |
trackOpens |
boolean | no | Set false to omit Smailor's open-tracking pixel for this message; account tracking opt-in still applies when omitted or true |
silent |
boolean | no | Set true to keep the sent message out of your mailbox. It is delivered normally but no conversation appears in the dashboard until the recipient replies, which brings the conversation back as an open, unread one |
At least one of text or html must be non-empty.
For marketing or subscribed mail, supply both email headers in the JSON body:
{
"headers": {
"List-Unsubscribe": "",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
},
"trackOpens": false
}The HTTPS URL must identify the recipient and list and accept Gmail's POST
without a login or confirmation. Smailor passes these headers to its outbound
transports and signs both when Smailor performs DKIM signing. If you use your
own SMTP or API provider, confirm its received DKIM h= list includes both
headers. Other email headers are rejected with 400 invalid_headers rather
than silently ignored. A sender name comes from the sending address's Sender
name setting; the API from field only selects the address.
Example
curl -X POST "https://smailor.com/api/v1/send" \
-H "Authorization: Bearer sk_smailor_..." \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"from": "[email protected]",
"subject": "Welcome",
"text": "Your mailbox is now active."
}'Endpoint: Focus Board
GET /api/v1/focus
Scope: focus:read. The attention board as data — what Focus decided a human
should look at, in the same order the dashboard shows it. Cards are always scoped
to the key's owner.
Read-only by design. There is no endpoint to dismiss or reprioritise a card:
an integration that could would make the board disagree with what the team
actually did, and the board's value is that it is trustworthy at a glance.
Query parameters
| Parameter | Notes |
|---|---|
section |
One of do_now, needs_attention, waiting_too_long, quick_win, needs_review. Omit for every section. An unknown value is 400 invalid_section. |
limit |
Cards per section, clamped to 1–50. |
Success response
{
"generatedAt": "2026-09-26T08:15:00.000Z",
"total": 12,
"summary": { "do_now": 3, "needs_attention": 5 },
"sections": { "do_now": [ { "ticketId": "…", "subject": "…" } ] }
}503 unavailable means Focus is not enabled on the account — not a transient
error, so do not retry it on a loop.
Endpoint: Focus Verdict for One Conversation
GET /api/v1/focus/conversations/:id
Scope: focus:read. Covers both the priority and the risk verdict for a single
conversation. An id from another workspace reads as 404 not_found, the same as
an id that does not exist.
The response carries priority (level, section, state, score, reasonCodes,whyHere, suggestedAction, confidence), risk (level, score, factors), and
the surrounding facts: waitingMinutes, followUpCount, ownerId,handlingTime, promise, spamStatus, securityRisk, updatedAt.
reasonCodes are machine-stable and safe to branch on. whyHere is the sentence
a human reads and is not a stable contract.
Endpoint: Focus Promises
GET /api/v1/focus/promises
Scope: focus:read. Commitments detected in what your team wrote — "I'll get back
to you tomorrow" — with the deadline the wording implied.
| Parameter | Notes |
|---|---|
status |
open, overdue, completed, cancelled, or all (default). Anything else is 400 invalid_status. |
limit |
Maximum promises returned. |
Each entry carries id, conversationId, subject, senderEmail, promise,promiseText, dueAt, needsReview, status, overdueMinutes, confidence,detectedBy and createdAt.
dueAt is null when the wording carried no deadline. It is never a guess,
so treat null as "no deadline stated" rather than substituting one — andneedsReview marks the ones a human should confirm.
Endpoint: Contact Widget Submit
POST /api/widget/submit
Content-Type: application/json
Powers the embeddable contact forms generated in Settings -> Widgets. A website visitor fills in a form, and the message lands in your Smailor inbox as a new ticket from that visitor.
Unlike /api/v1/send, this endpoint delivers inbound to your own inbox, so to only has to be an active address on your account — a verified custom domain is not required.
Two ways to authenticate
| Mode | Header | Where it runs | Recipient |
|---|---|---|---|
| Publishable widget key (recommended for websites) | X-Widget-Key: wk_... |
In the page | Pinned to the form widget's address; to is ignored |
| Account API key | Authorization: Bearer ... or X-API-Key |
Your backend only | to is required |
A publishable key is created per address in Settings / Widgets / Contact forms. It can only deliver into that address, can be restricted to your websites, and is rate-limited to 10 submissions per 10 minutes per visitor IP. See the widgets guide for the full model, and for the live chat widget, which is built on the same keys.
JSON body
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | API key only | Must be one of your active Smailor addresses. Ignored with a publishable key |
from |
string | yes | Visitor's email; appears as the sender in your inbox |
name |
string | no | Visitor display name, max 255 chars |
subject |
string | yes | 1–998 chars |
message |
string | yes | Plain text, 1–100000 chars. The HTML body is generated and sanitized server-side |
attachments |
array | no | Files uploaded by the visitor — see Attachments |
Attachments
Each entry of attachments is an object:
| Field | Type | Required | Notes |
|---|---|---|---|
filename |
string | yes | 1–255 chars. Shown on the ticket |
mimeType |
string | no | e.g. image/png. Falls back to the file extension when omitted |
contentBase64 |
string | yes | Base64 of the file bytes. A data: URL prefix (data:image/png;base64,…) is accepted and stripped |
Default limits — configurable per deployment:
| Limit | Default | Environment variable |
|---|---|---|
| Files per submission | 3 | WIDGET_MAX_ATTACHMENTS |
| Size per file | 5 MB | WIDGET_ATTACHMENT_MAX_PER_FILE_MB |
| Total size per submission | 10 MB | WIDGET_ATTACHMENT_MAX_TOTAL_MB |
The widget limits are always clamped to the mailbox-wide ones (ATTACHMENT_MAX_PER_FILE_MB,ATTACHMENT_MAX_PER_TICKET_MB), and the whole request must still fit under BODY_SIZE_LIMIT.
Base64 inflates payloads by roughly one third, so budget accordingly.
Accepted file types. Documents (PDF, Office, OpenDocument, RTF, TXT, CSV, Markdown, JSON,
XML), images, audio, video and archives. Executables, scripts and active web content are
refused. The declared mimeType is only a hint: the file is re-identified from its magic bytes
before it is written to disk, so renaming an executable does not get it through.
Example
curl -X POST "https://smailor.com/api/widget/submit" \
-H "Authorization: Bearer sk_smailor_..." \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"from": "[email protected]",
"name": "Jane Doe",
"subject": "Question about pricing",
"message": "Hi, do you offer annual billing?"
}'With an attachment:
curl -X POST "https://smailor.com/api/widget/submit" \
-H "Authorization: Bearer sk_smailor_..." \
-H "Content-Type: application/json" \
-d "{
\"to\": \"[email protected]\",
\"from\": \"[email protected]\",
\"subject\": \"Invoice problem\",
\"message\": \"The total on this invoice looks wrong.\",
\"attachments\": [{
\"filename\": \"invoice.pdf\",
\"mimeType\": \"application/pdf\",
\"contentBase64\": \"$(base64 -w0 invoice.pdf)\"
}]
}"From a browser, read the file with FileReader.readAsDataURL and send everything after the
comma — that is exactly what the generated Settings → Widgets contact-form snippet does:
const toAttachment = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result);
resolve({
filename: file.name,
mimeType: file.type || undefined,
contentBase64: result.slice(result.indexOf(',') + 1),
});
};
reader.onerror = () => reject(new Error(`Could not read ${file.name}`));
reader.readAsDataURL(file);
});
const attachments = await Promise.all([...fileInput.files].map(toAttachment));Success response
{ "ok": true, "messageId": "", "attachments": 1 }attachments is the number of files accepted for the ticket.
Errors
| Status | Error | Meaning |
|---|---|---|
400 |
invalid_body, invalid_headers |
Schema or one-click email header validation failed |
400 |
invalid_to |
The to address is not an active address on your account |
400 |
too_many_attachments |
More files than WIDGET_MAX_ATTACHMENTS allows |
400 |
attachment_too_large |
A file, or the batch, exceeds the size limit |
400 |
attachment_type_rejected |
The file type is not on the allowlist |
400 |
invalid_attachment |
Missing filename, empty file, or malformed base64 |
401 |
unauthorized |
Missing or invalid API key |
503 |
service_unavailable |
Message queue temporarily unavailable — retry |
500 |
internal_error |
Temporary server issue |
Attachment errors carry a human-readable message naming the offending file, plus a limits
object with the effective maxCount, maxPerFileBytes and maxTotalBytes — show it to the
visitor as-is:
{
"error": "attachment_too_large",
"message": "\"screenshot.png\" exceeds the 5 MB per-file limit.",
"limits": { "maxCount": 3, "maxPerFileBytes": 5242880, "maxTotalBytes": 10485760 }
}CORS
The endpoint answers OPTIONS preflight and allows cross-origin POST, so it can be called from your website's front-end. Restrict the allowed origins with the API_CORS_ORIGIN or API_CORS_ALLOWLIST environment variables (default: *).
Keep your API key server-side
Your API key grants full access to your account, not just widget submissions. Pasting it into front-end JavaScript makes it readable by any visitor via page source or the network tab, and reusable to send mail in your name.
For production, put the key behind your own server and have the browser call your endpoint instead:
// your-server/contact → proxies to Smailor with the key from an env var
const upstream = await fetch('https://smailor.com/api/widget/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.SMAILOR_API_KEY}`,
},
body: JSON.stringify({ to: RECIPIENT, from: visitorEmail, subject, message, attachments }),
});If you forward attachments through your own server, raise its body-size limit too — Express
defaults to 100 KB (app.use(express.json({ limit: '12mb' }))).
Set the recipient (to) on your server too — never from the request body — otherwise the endpoint lets anyone send mail to any of your addresses.
Customer SMS Webhooks
Configure customer webhook URLs in Settings → API keys → Customer webhooks. Each endpoint can opt into sms.sent, sms.delivered, sms.failed, and sms.received independently of inbound.ticket_created. Existing endpoints continue to receive ticket events until you turn that checkbox off. SMS events require the Telecom add-on and SMS to be enabled.
The endpoint receives an HTTPS POST with JSON. For example:
{
"event": "sms.delivered",
"occurred_at": "2026-10-04T12:00:00.000Z",
"message": {
"id": "11111111-1111-4111-8111-111111111111",
"ticket_id": null,
"contact_id": null,
"direction": "outbound",
"status": "delivered",
"to": "+33612345678",
"from": "Smailor",
"segments": 1
}
}sms.failed adds message.error_code; sms.received adds message.body and message.in_reply_to (the outbound SMS ID). Outbound message text and provider IDs are not included. A 2xx response acknowledges a delivery. Smailor retries other responses and timeouts up to eight times with exponential backoff. Delivery is at least once, so deduplicate using the stable X-Smailor-Delivery header. X-Smailor-Event and X-Smailor-Webhook-Id identify the event and endpoint. If a signing secret is configured, verify X-Smailor-Signature: sha256=<hex> as HMAC-SHA256 of the raw request body.
Campaigns can be launched from Settings → SMS → SMS campaigns, or with POST /api/v1/sms/campaigns using the sms:campaigns:write scope and an Idempotency-Key header. The Telecom plan must have campaigns enabled. The JSON body contains name, templateId (an approved template, see Templates below), recipients (1–500 E.164 numbers), and optionally variables, senderId and replyEnabled. Campaigns use the ordinary per-message queue; recipients are queued in batches by the campaign dispatcher, normally within a minute. GET /api/v1/sms/campaigns and GET /api/v1/sms/campaigns/{id} use sms:campaigns:read and return current counts and recipient status. The API returns 202 once the campaign and recipient list are saved.
Subscribe to campaign.started, campaign.completed, and campaign.failed on the same customer webhook endpoints. A campaign completes when every recipient has either been accepted by the SMS provider or definitively failed. campaign.failed means no recipient was accepted by the provider. Later delivery receipts continue to update the campaign's delivered and failed counts. Campaign webhook bodies include event, occurred_at, and campaign with id, status, total, sent, delivered, and failed.
SMS refusals worth handling
Every SMS goes through the same content check as Smailor email before anything is counted. A refused SMS uses no quota and no credit.
| Error | Status | Meaning |
|---|---|---|
content_blocked |
422 | The text failed a safety check (a dangerous link, for example). Change the message. |
content_review |
422 | The text looks like SMS phishing (a parcel fee or an administration with a link to a domain your account has not verified). Verify the domain in Smailor, or contact support. |
verification_required |
403 | The account is held for a review by Smailor. Sending resumes once it is approved. |
quota_exceeded |
402 | The monthly SMS quota is used. The body has upgradeAvailable: true when a larger Telecom plan can add SMS for the rest of the period. |
destination_unavailable |
403 | The country is not open. Ask for it below. |
Templates
A template is fixed text with blanks, such as Bonjour {{first_name}}, votre rendez-vous est confirme le {{date}} a {{time}}. The blanks are a fixed list, each with a type: first_name, last_name and company (text up to 40 characters, no link), date, time, order_id and reference (letters, digits, -, _, /), amount, code (4 to 10 letters or digits, for sign-in codes) and url (an https link on a domain verified on your account). Most of a template must be fixed text.
POST /api/v1/sms/templates with sms:send and { "name", "body" } answers 201 with status approved when Smailor's content check finds nothing, or pending while Smailor checks it. GET /api/v1/sms/templates with sms:read lists them. Send from an approved template with POST /api/v1/sms/send and { "to", "templateId", "variables": { "first_name": "Marie", ... } } instead of message; every blank must be filled. The rendered text is checked like any SMS. Campaigns are always sent from an approved template: POST /api/v1/sms/campaigns takes templateId and variables (the same values for every recipient) and refuses a free message with template_required.
Asking for a country
POST /api/v1/sms/destination-requests with the sms:send scope asks Smailor to open a country. The JSON body has country (ISO 3166 code such as DE), useCase (10 to 500 characters) and optionally monthlyVolume. A second request for the same country updates the first and answers 200; a new one answers 201. At most 10 requests per account wait for an answer at a time. GET /api/v1/sms/destination-requests with sms:read lists them with their status (open, planned, added, declined) and Smailor's note. The account owner also gets an email when a country is added or declined.
Common Errors
Applies to /api/v1 endpoints.
| Status | Error | Meaning |
|---|---|---|
400 |
invalid_body, invalid_headers, domain_not_verified, invalid_section, invalid_status |
Validation or domain verification issue |
401 |
unauthorized |
Missing or invalid API key |
403 |
forbidden |
Plan or account restriction — the REST API is not available on Free |
403 |
insufficient_scope |
The key is valid but lacks the scope the endpoint requires |
404 |
not_found, user_not_found |
No such resource, or none visible to this key |
409 |
address_taken, domain_reputation_blocked |
Conflict or safety guard triggered |
429 |
too_many_requests |
Send frequency limit hit |
503 |
unavailable |
The feature is not enabled on this account (e.g. Focus) |
5xx |
internal_error |
Temporary server issue |
403 and 503 are decisions, not transient failures: retrying either without
changing the key or the plan produces the same answer.
Best Practices for Reputation Safety
- Use double opt-in for new senders
- Warm up new domains gradually
- Stop onboarding when bounce/complaint rates increase
- Separate transactional and marketing traffic per domain
- Monitor domain-level events daily
For bulk marketing or subscription mail to personal Gmail accounts, see
one-click unsubscribe requirements.
The send API accepts the RFC 8058 unsubscribe header pair; customers must host
the unsubscribe endpoint and suppress opted-out recipients. Smailor's own
outreach workflow handles its own opt-outs.
Reply Templates (Web Session API)
These routes power the in-app template editor and ticket composer. They authenticate with the same session as the dashboard (browser cookies after login), not with Authorization: Bearer sk_… keys used for /api/v1.
Use cases: building automation around your own logged-in browser session, internal tools same-origin with Smailor, or understanding what the UI calls.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/templates |
List your templates. Optional query: ?groupId= (UUID) or ?ticketId= (UUID) to scope suggestions like the ticket reply UI. |
POST |
/api/templates |
Create a template (JSON body below). Max 50 templates per account. |
GET |
/api/templates/{id} |
Load one template (owner only). |
PATCH |
/api/templates/{id} |
Update fields (name, description, subject, body_html, body_text, is_default, group_ids). |
DELETE |
/api/templates/{id} |
Delete template. |
POST |
/api/templates/{id}/preview |
Body: { "ticketId": "<uuid>" } — returns rendered subject, html, text for that ticket. |
POST |
/api/templates/{id}/use |
Fire-and-forget use counter (called when a template is inserted in the UI). |
Create template (POST /api/templates)
Content-Type: application/json
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Display name |
description |
string | no | Short note |
subject |
string | yes | Can include {{placeholders}} |
body_html |
string | yes | HTML body, min length 10 |
body_text |
string | no | Plain text; if omitted, derived from HTML |
is_default |
boolean | no | Mark as default |
group_ids |
string[] (UUID) | no | Restrict template to groups; omit or empty = all groups |
Placeholders
Subject and HTML may use {{variable}} tokens. Common keys: customer_name, customer_email, ticket_id, subject, group_name, sender_name, company_name, date. Unknown keys are left unchanged at render time.
Example (session required — copy your logged-in Cookie header from the browser on your own origin)
curl -X POST "https://smailor.com/api/templates" \
-H "Content-Type: application/json" \
-H "Cookie: " \
-d '{
"name": "Thanks",
"subject": "Re: {{subject}}",
"body_html": "Hi {{customer_name}},
Thanks for your message.
"
}'Errors mirror other JSON APIs: 401 when not signed in, 400 for validation or template quota, 403/404 when listing with ticketId you cannot access.
See Also
Outbound conversation capacity
| Plan | Monthly subscription | Annual subscription | Outbound conversations / month |
|---|---|---|---|
| Free | €0.00 | €0.00 | 100 |
| Solo | Annual only | €19.90 | 500 |
| Starter | €3.99 | €39.90 | 1,500 |
| Pro | €9.99 | €99.90 | 5,000 |
| Business | €31.99 | €319.90 | 20,000 |
Prices are in EUR, excluding VAT. Solo is annual-only. Annual Starter, Pro and Business cost ten monthly payments. Usage allowances renew every calendar month, including on annual plans.
| Additional monthly capacity | Monthly recurring price per pack |
|---|---|
| +1,000 outbound conversations | €2.39 |
| +2,500 outbound conversations | €4.49 |
| +5,000 outbound conversations | €7.49 |
| +10,000 outbound conversations | €11.99 |
| +25,000 outbound conversations | €22.99 |
Available on every plan, including Free. Packs are stackable and always billed monthly.
A new conversation started by you counts as one outbound conversation. Replies don't count.
POST /api/v1/send starts a new conversation and consumes one outbound unit when accepted into the queue. Retries with the same idempotency key do not consume another unit. Existing-thread replies and SMTP messages with a valid owned thread reference do not consume another unit. remaining reports available outbound capacity, including paid packs, for the UTC calendar month. 429 send_limit_reached means no capacity for new conversations; existing replies still work. Rate-limit errors are independent and may affect replies too.
Usage is shared across the account and its team, including your own sending provider. Packs do not grant paid API access on Free. See plans and outbound packs for the complete counting, reset and billing rules.