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 conservatively

Base 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_KEY

or:

X-API-Key: YOUR_API_KEY

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

  1. Add and verify a custom domain.
  2. Call GET /api/v1/domains and require the expected domain to be verified.
  3. Create member addresses with POST /api/v1/mailboxes when needed.
  4. Send a single controlled message with POST /api/v1/send.
  5. Confirm receipt and authentication at the destination mailbox.
  6. Handle documented 4xx responses explicitly and retry only temporary
    failures.
  7. 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:

  • blocked status => API returns 409 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 — and
needsReview 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.

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.

Back to docs

Found an issue on this page?