Emails
| Endpoint | Does |
|---|---|
| POST /emails | Send 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/batch | Up to 100 per call, each independently accepted or rejected, preserving per-email provenance |
| GET /emails/:id | Status, full event trail, rendered body, and origin provenance (channel, source, agentName) |
| GET /emails | Filter by status, recipient, domain, date, tag, channel (mcp, sdk, api, smtp), source, and agent_name |
| DELETE /emails/:id | Cancel a scheduled send before it leaves the queue |
| POST /emails/:id/resend | Send it again with the key that sent it. Refused if that key was revoked or the body has aged out |
| GET /emails/export | The 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.
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.
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.
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).
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.
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.
{
"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>"
}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.
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.
{ "from": "…", "to": "…", "subject": "…", "html": "…", "dry_run": true }Domains, keys, webhooks
| Endpoint | Does |
|---|---|
| GET /account | Account plan, monthly email quota, domain/seat utilization, reputation level, and retention days |
| PATCH /account | Update organization name, toggle open/click tracking, or cancel scheduled account deletion |
| GET /account/volume | Sparkline volume history array over a window up to 90 days (default ?window=14d) |
| GET /account/stats | Sends, deliveries, bounce rate and complaint rate. ?provenance=true adds byChannel, bySource, and topAgents matrices |
| POST /domains | Generates a DKIM keypair and returns the exact records to paste |
| GET /domains | List registered sending domains and their DNS verification statuses |
| GET /domains/:id/records | Fetch required SPF, DKIM, DMARC, and MX DNS records for a domain |
| POST /domains/:id/verify | Live DNS lookup with per-record pass or fail and the reason |
| GET /domains/history | Deleted domain history with preserved DKIM records for 1-click restore |
| POST /domains/:id/restore | Restore a previously removed domain without generating new DNS keys |
| DELETE /domains/:id | Safely delete a custom domain from the account |
| GET/POST/PATCH/DELETE /keys | Scopes, rate limit, recipient allowlist, CIDR IP allowlist, expiration date, and agent persona |
| GET/POST /keys/approvals | Read and clear sends held by an approval-mode key |
| POST /mcp | Model Context Protocol (MCP) JSON-RPC 2.0 streamable endpoint |
| GET/POST/DELETE /webhooks | Signed event delivery, plus POST /webhooks/:id/test |
| GET/POST/PATCH/DELETE /webhooks | Signed event delivery, plus POST /webhooks/:id/test |
| POST /webhooks/simulate | Live webhook delivery simulator to local developer endpoints (http://localhost:PORT) with HMAC verification |
| GET /webhooks/:id/deliveries | Every attempt: status, error and duration, per event |
| POST /webhooks/:id/replay | Re-send events from a window, up to 7 days. Refused for a disabled endpoint |
| GET/POST/PATCH /templates | Versioned templates, plus POST /templates/:id/render |
| GET/POST/PATCH/DELETE /templates | Versioned Liquid templates with rollback support |
| POST /templates/:id/render | Render a stored Liquid template against sample variables without sending |
| POST /templates/:id/test | Dispatch test send with custom JSON variables to your own inbox |
| GET/POST/DELETE /suppressions | Read, add, remove. Removal is audited |
| GET /account/stats | Sends, 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/tickets | Raise a ticket and read the thread, with the context the app captured |
| POST /tools/:check | SPF, DKIM, DMARC, MX, blocklist and inbox placement. No authentication |