Skip to content

API reference

Last updated 7 September 2026

A small surface, deliberately. Every endpoint here earns its place.

Emails

EndpointDoes
POST /emailsSend one. html, text, or template + variables. Supports cc, bcc, reply_to, tags, attachments, scheduled_at, dry_run, plus channel, source, and agent_name
POST /emails/batchUp to 100 per call, each independently accepted or rejected, preserving per-email provenance
GET /emails/:idStatus, full event trail, rendered body, and origin provenance (channel, source, agentName)
GET /emailsFilter by status, recipient, domain, date, tag, channel (mcp, sdk, api, smtp), source, and agent_name
DELETE /emails/:idCancel a scheduled send before it leaves the queue
POST /emails/:id/resendSend it again with the key that sent it. Refused if that key was revoked or the body has aged out
GET /emails/exportThe log as CSV or JSON, streamed. Same filters as GET /emails, plus from and to dates

Sending an email

The API never calls the sending provider synchronously. You get an id in milliseconds; delivery happens on a worker and is reported through webhooks or by polling the message.

JSON
POST /emails
{
  "from": "you@yourdomain.com",
  "to": "user@example.com",
  "cc": ["team@example.com"],
  "bcc": ["compliance@yourdomain.com"],
  "reply_to": "support@yourdomain.com",
  "subject": "Welcome aboard",
  "html": "<strong>It works.</strong>",
  "agent_name": "Claude Support Agent",
  "source": "agent"
}

202 Accepted
{ "id": "26abdd24-36a9-475d-83bf-4d27a31c7def", "status": "queued" }

Agent and Channel Provenance

Every email carries origin provenance tracking who and what sent it:

channel: Ingress protocol — api (standard REST), sdk (Node/Python clients), mcp (Model Context Protocol), or smtp (MTA relay).

source: Sending persona — agent (autonomous AI bots), backend (application services), automation (scheduled cron jobs), or system (platform transactional alerts).

agent_name: An optional identifier (up to 128 chars) attributing the email to a specific bot, e.g. "LeadGen AutoGPT" or "Claude 3.7 Assistant".

You can set provenance explicitly in the JSON body (agent_name, source), pass zero-code headers (X-SadaSend-Agent, X-SadaSend-Source), or configure default personas directly on your API keys.

HTTP
POST /emails
Authorization: Bearer sada_live_sk_
X-SadaSend-Agent: LeadGen AutoGPT
X-SadaSend-Source: agent
Content-Type: application/json

{
  "from": "sales@yourdomain.com",
  "to": "prospect@target.com",
  "subject": "Partnership inquiry",
  "text": "Hello, reaching out regarding your API needs."
}

Batch sending

Send up to 100 emails in a single atomic HTTP request with POST /emails/batch.

Every message is evaluated independently through acceptMessage. A suppressed recipient or allowlist refusal on message #42 never fails the rest of the batch — each message gets its own outcome in the results array.

The call always returns 207 Multi-Status inside the 2xx range. Each item derives its own idempotency key (${Idempotency-Key}:${index}), so retrying the whole batch safely replays accepted messages while retrying failed ones.

JSON
POST /emails/batch
Authorization: Bearer sada_live_sk_
Idempotency-Key: batch-2026-09-07-001
Content-Type: application/json

{
  "emails": [
    {
      "from": "notifications@yourdomain.com",
      "to": "alice@example.com",
      "subject": "Order #1201 Confirmed",
      "html": "<p>Hi Alice, your order is confirmed.</p>",
      "tags": ["orders"]
    },
    {
      "from": "notifications@yourdomain.com",
      "to": "bob@example.com",
      "subject": "Order #1202 Confirmed",
      "html": "<p>Hi Bob, your order is confirmed.</p>",
      "tags": ["orders"]
    }
  ]
}

207 Multi-Status
{
  "accepted": 2,
  "refused": 0,
  "total": 2,
  "results": [
    { "index": 0, "ok": true, "id": "4092b1a8-36a9-475d-83bf-4d27a31c7def", "status": "queued" },
    { "index": 1, "ok": true, "id": "91a4f8c2-12e4-49fb-9ac7-1b32d849fae0", "status": "queued" }
  ]
}

Listing and searching messages

GET /emails queries message activity with keyset-based cursor pagination. Unlike offset pagination, keyset cursors stay stable and performant as new emails are continuously ingested.

Filter by status (e.g. queued, delivered, bounced), recipient or subject search (q), labels (tag), and provenance dimensions (channel, source, agent_name).

JSON
GET /emails?channel=mcp&source=agent&status=delivered&limit=25
Authorization: Bearer sada_live_sk_

200 OK
{
  "messages": [
    {
      "id": "26abdd24-36a9-475d-83bf-4d27a31c7def",
      "from": "support@yourdomain.com",
      "to": "customer@example.com",
      "subject": "Ticket resolved",
      "status": "delivered",
      "channel": "mcp",
      "source": "agent",
      "agentName": "Billing Resolver",
      "tags": ["support"],
      "createdAt": "2026-09-07T12:00:00.000Z"
    }
  ],
  "total": 1,
  "nextCursor": null
}

Streaming message export

GET /emails/export streams your message history up to 100,000 rows as csv, json, or jsonl (NDJSON). Rows are fetched in keyset batches and streamed immediately rather than buffered in server memory.

Supports all filters from GET /emails, plus from and to ISO timestamps to bound the export window.

HTTP
GET /emails/export?format=csv&channel=mcp&from=2026-09-01T00:00:00Z&to=2026-09-07T23:59:59Z
Authorization: Bearer sada_live_sk_

200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="sadasend-messages-2026-09-07.csv"

id,created_at,from,to,subject,status,channel,source,agent_name,tags
26abdd24-,2026-09-07T12:00:00.000Z,support@yourdomain.com,customer@example.com,"Ticket resolved",delivered,mcp,agent,"Billing Resolver","support"

Resending and cancelling

POST /emails/:id/resend re-dispatches a message under its original issuing key. Because this creates a new send with its own event trail, it runs through acceptMessage to verify recipient suppressions and current quota.

DELETE /emails/:id (or POST /emails/:id/cancel) removes a message while it is still in queued status, deleting it from the BullMQ background outbox worker before it leaves for the mail provider.

Copies and replies

cc and bcc take one address or several, exactly as to does. Both are recipients in every sense that matters: they count against your quota, and they are checked against the key’s recipient allowlist and your suppression list. Putting an address in bcc is not a way around a guardrail.

bcc is stored on the message so the log can answer who received it, and is never written into the message itself. reply_to is not a recipient — it costs no quota and is checked against neither the allowlist nor suppression.

JSON
{
  "from": "you@yourdomain.com",
  "to": "user@example.com",
  "cc": ["colleague@example.com"],
  "bcc": ["archive@yourdomain.com"],
  "reply_to": "support@yourdomain.com",
  "subject": "Welcome aboard",
  "html": "<strong>It works.</strong>"
}

Tags

Up to ten labels on a message, lowercased and de-duplicated so Welcome and welcome are one tag rather than two rows in a breakdown nobody can read.

They earn their place on the stats: GET /account/stats?by_tag=true gives each tag its own bounce and complaint rate, against its own send count. "Your bounce rate is 2%" is a number nobody can act on; "your bounce rate is 2% and all of it is the invite email" is a fix.

On the log, repeat ?tag= to narrow — ?tag=billing&tag=retry is the billing retries, not billing plus retries.

JSON
{ "from": "…", "to": "…", "subject": "…", "html": "…", "tags": ["welcome", "onboarding"] }

Open and click tracking

Both are off on every account. Turn them on with PATCH /account{ "trackOpens": true }, { "trackClicks": true } — and GET /account reports the current state.

With opens on, a 1×1 image is appended to your HTML and a fetch records an opened event. With clicks on, links are rewritten to pass through a redirect that records a clicked event and then sends the recipient to your original URL. Neither records the recipient’s IP address or user agent.

Two things are deliberately never rewritten: the one-click unsubscribe link, which must not gain a hop, and any URL containing template variables, because it is not known until the message renders.

GET /account/stats?engagement=true adds opens and clicks. Each counts messages opened or clicked at least once, not events — four views of one email is not a 400% open rate. Each block is absent unless that tracking is on, because "not measured" is a different statement from zero.

JSON
PATCH /account
{ "trackOpens": true, "trackClicks": true }

GET /account/stats?window=14d&engagement=true
{
  "window": "14d",
  "sent": 1200,
  "opens":  { "tracked": 1200, "opened": 486, "openRate": 40.5 },
  "clicks": { "tracked": 1200, "clicked": 91, "clickRate": 7.58 }
}

Why a message bounced

Bounce and complaint events carry a classification: hard the address does not exist, soft temporarily undeliverable, block the receiver refused the mail on policy or content, spam the recipient pressed the button. Every other event type carries null.

The distinction is the whole point. A bounce rate made of soft bounces is a retry problem; the same rate made of blocks is a reputation problem, and the thing to change is on your side. unknown means the provider could not say — not that it was minor.

Dry run

Set dry_run to true and the request is validated, rendered and checked against your suppression list, then returns exactly what would have been sent — without sending it. This is what lets an agent compose and self-correct without touching a mailbox.

JSON
{ "from": "…", "to": "…", "subject": "…", "html": "…", "dry_run": true }

Domains, keys, webhooks

EndpointDoes
GET /accountAccount plan, monthly email quota, domain/seat utilization, reputation level, and retention days
PATCH /accountUpdate organization name, toggle open/click tracking, or cancel scheduled account deletion
GET /account/volumeSparkline volume history array over a window up to 90 days (default ?window=14d)
GET /account/statsSends, deliveries, bounce rate and complaint rate. ?provenance=true adds byChannel, bySource, and topAgents matrices
POST /domainsGenerates a DKIM keypair and returns the exact records to paste
GET /domainsList registered sending domains and their DNS verification statuses
GET /domains/:id/recordsFetch required SPF, DKIM, DMARC, and MX DNS records for a domain
POST /domains/:id/verifyLive DNS lookup with per-record pass or fail and the reason
GET /domains/historyDeleted domain history with preserved DKIM records for 1-click restore
POST /domains/:id/restoreRestore a previously removed domain without generating new DNS keys
DELETE /domains/:idSafely delete a custom domain from the account
GET/POST/PATCH/DELETE /keysScopes, rate limit, recipient allowlist, CIDR IP allowlist, expiration date, and agent persona
GET/POST /keys/approvalsRead and clear sends held by an approval-mode key
POST /mcpModel Context Protocol (MCP) JSON-RPC 2.0 streamable endpoint
GET/POST/DELETE /webhooksSigned event delivery, plus POST /webhooks/:id/test
GET/POST/PATCH/DELETE /webhooksSigned event delivery, plus POST /webhooks/:id/test
POST /webhooks/simulateLive webhook delivery simulator to local developer endpoints (http://localhost:PORT) with HMAC verification
GET /webhooks/:id/deliveriesEvery attempt: status, error and duration, per event
POST /webhooks/:id/replayRe-send events from a window, up to 7 days. Refused for a disabled endpoint
GET/POST/PATCH /templatesVersioned templates, plus POST /templates/:id/render
GET/POST/PATCH/DELETE /templatesVersioned Liquid templates with rollback support
POST /templates/:id/renderRender a stored Liquid template against sample variables without sending
POST /templates/:id/testDispatch test send with custom JSON variables to your own inbox
GET/POST/DELETE /suppressionsRead, add, remove. Removal is audited
GET /account/statsSends, deliveries, bounce rate and complaint rate. ?provenance=true adds byChannel, bySource, and topAgents breakdown matrices. ?by_tag=true splits per tag, ?bounces=true splits bounces by kind, ?engagement=true adds open and click rates
GET/POST /support/ticketsRaise a ticket and read the thread, with the context the app captured
POST /tools/:checkSPF, DKIM, DMARC, MX, blocklist and inbox placement. No authentication