Send email
Roadmap — Outbound Email P1 — transactional send with truthful delivery analytics.Send transactional email through the same engine that delivers your webhooks: one accept → queue → deliver → retry → DLQ → replay pipeline, one delivery log, one set of rollups. An email is just a delivery whose final hop goes to Amazon SES instead of an HTTPS endpoint — so every message is idempotent, retried, logged, and replayable, and every SES lifecycle event (delivered, bounced, complained) is correlated back to the message and routable as an ordinary event.
Scope of P1
Transactional send + delivery-truth analytics: sent → delivered, bounce/complaint tracking, an org suppression list, per-message activity timelines, and funnel analytics in the console. No open/click tracking, broadcasts, or unsubscribe management in this phase.
Sending identities
You send from a verified sending identity — a domain SES has verified you own. Two paths:
| Identity | DNS | From address |
|---|---|---|
| Platform | none — provisioned for you | no-reply@<org-slug>.<platform-send-domain> |
| Custom domain | your own domain, upgraded for sending (DKIM already verified + a MAIL FROM MX/SPF pair) | any address @your-domain.com |
Every org gets a platform identity lazily on first send — no setup required to start. To send from your own domain, verify it as an email domain, then enable Outbound sending on its detail page in the console (this adds the MAIL FROM records and re-runs verification).
Send one email
curl -X POST https://api.emithook.com/v1/emails \
-H "Authorization: Bearer $EK_KEY" \
-H "Idempotency-Key: welcome-user-8f2a" \
-H "Content-Type: application/json" \
-d '{
"from": "no-reply@acme.send.emithook.com",
"to": ["buyer@example.com"],
"subject": "Your invoice is ready",
"html": "<p>Thanks for your order — <a href=\"https://acme.com/i/INV-1\">view invoice</a>.</p>"
}'// → 202 Accepted
{ "id": "evt_01JX9..." }from's domain must match one of your active sending identities, or the call returns422(details.check: from_address).- At least one of
html/textis required.to/cc/bccare arrays (≤ 50 recipients). headersare additive customX-*headers only — you can't overridefrom/to/subjectthrough them.- Send
Idempotency-Keyso a client retry can't double-send. - A recipient list that is entirely suppressed (see below) is accepted and stored as a terminal
suppressedmessage rather than rejected; a partially-suppressed list sends only to the surviving recipients.
Attachments
Each attachment is either inline base64 or a public https URL Emithook fetches for you — exactly one of the two per item:
curl -X POST https://api.emithook.com/v1/emails \
-H "Authorization: Bearer $EK_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "no-reply@acme.send.emithook.com",
"to": ["buyer@example.com"],
"subject": "Your invoice",
"html": "<p>Invoice attached.</p>",
"attachments": [
{ "filename": "invoice.pdf", "content_type": "application/pdf", "content": "JVBERi0xLjQK…" },
{ "url": "https://cdn.acme.com/terms.pdf" }
]
}'- Limits: up to 20 attachments and 10 MB of decoded bytes across the whole request — and a batch shares one budget, so 100 messages get the same 10 MB and the same 20 URL fetches that one message gets, not 100× either. Over any of those is
422withdetails.check: attachments. Above roughly 7 MB you cross SES's bandwidth-throttling line — still delivered, just paced by the provider. - A URL is fetched before the
202, not at delivery time. That keeps sending predictable: every retry and replay sends exactly the bytes fetched at accept, even if the origin changes or disappears. It also means a broken URL fails the call, where you can act on it, rather than surfacing later as a dead message. - Redirects are not followed. Signed object-store and CDN links often redirect; those are refused with a message telling you to pass the final URL, or to inline the bytes. Fetches are
httpsonly, never carry credentials, and are blocked from internal addresses. filenameis required for inline content and defaults to the URL's last path segment when fetching.content_idsets the part's Content-ID so HTML can reference an inline image ascid:logo.- File types are not policed —
.zip,.csvand.xlsxare ordinary transactional attachments.
Batch
POST /v1/emails/batch accepts up to 100 messages; each is validated and suppression-checked independently, so one bad item never fails the rest:
curl -X POST https://api.emithook.com/v1/emails/batch \
-H "Authorization: Bearer $EK_KEY" \
-H "Content-Type: application/json" \
-d '{ "emails": [ { "from": "no-reply@acme.send.emithook.com", "to": ["a@example.com"], "subject": "Hi", "text": "…" } ] }'// → 202 — one result per input item, in order
{ "data": [ { "id": "evt_01JX9..." } ] }Track a message
Every message carries a status and a lifecycle timeline — the SES facts (sent → delivered, or bounced/complained) correlated back as they arrive:
curl https://api.emithook.com/v1/emails/evt_01JX9... \
-H "Authorization: Bearer $EK_KEY"{
"id": "evt_01JX9...",
"from": "no-reply@acme.send.emithook.com",
"to": ["buyer@example.com"],
"subject": "Your invoice is ready",
"status": "delivered",
"created_at": "2026-07-25T10:00:00.000Z",
"attempts": [ { "number": 1, "status": 200, "duration_ms": 120, "at": "2026-07-25T10:00:01.000Z" } ],
"timeline": [
{ "type": "sent", "at": "2026-07-25T10:00:01.000Z" },
{ "type": "delivered", "at": "2026-07-25T10:00:05.000Z" }
]
}GET /v1/emails lists your messages, filterable by status (sending/delivered/bounced/complained/suppressed/failed/dlq) and since/until, cursor-paginated. Messages also remain visible in the general GET /v1/events — this is a more granular view, not a separate store. The console's Outbound email screen renders the same data as funnel tiles + a per-message timeline.
The suppression list
To protect your sender reputation, a hard bounce or a spam complaint auto-adds the recipient to your org's suppression list, and a suppressed address is never sent to again — the check runs at accept time and is re-checked on every retry and replay, so a listed address can never slip through. Manage the list directly:
# List
curl https://api.emithook.com/v1/suppressions -H "Authorization: Bearer $EK_KEY"
# Add (always reason:"manual" — bounce/complaint entries are added automatically)
curl -X POST https://api.emithook.com/v1/suppressions \
-H "Authorization: Bearer $EK_KEY" -H "Content-Type: application/json" \
-d '{ "address": "opted-out@example.com" }'
# Remove (lets the address receive mail again)
curl -X DELETE https://api.emithook.com/v1/suppressions/esup_01JX9... \
-H "Authorization: Bearer $EK_KEY"SDK
import { readFile } from 'node:fs/promises'
import { EmithookClient } from '@emithook/sdk'
const emithook = new EmithookClient({ apiKey: process.env.EK_KEY! })
const pdf = await readFile('./invoice.pdf')
const { id } = await emithook.emails.send({
from: 'no-reply@acme.send.emithook.com',
to: ['buyer@example.com'],
subject: 'Your invoice is ready',
html: '<p>Thanks for your order.</p>',
attachments: [
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: pdf.toString('base64') },
{ url: 'https://cdn.acme.com/terms.pdf' }, // fetched at accept time
],
}, { idempotencyKey: 'welcome-user-8f2a' })
const email = await emithook.emails.get(id) // status + timeline
for await (const e of emithook.iterateEmails({ status: 'bounced' })) console.log(e.id)
await emithook.suppressions.create({ address: 'opted-out@example.com' })CLI
emithook email send \
--from no-reply@acme.send.emithook.com \
--to buyer@example.com \
--subject "Your invoice is ready" \
--text "Thanks for your order." \
--attach ./invoice.pdf --attach-url https://cdn.acme.com/terms.pdf
emithook email list --status bounced --limit 20Deliverability
Sender reputation is shared. SES watches two account-level rates: a hard bounce rate (over 2% is the warn line; over 5% SES may pause sending) and a complaint rate (over 0.1% is the danger line — it risks the whole account). Send only to recipients who expect your mail, honour the suppression list, and watch the bounce/complaint tiles on the console's Outbound email screen — they colour warn/danger at exactly these lines. If a rate spikes, self-host operators should follow the email-deliverability runbook (in the repo under docs/runbooks/).
Next
- API conventions — idempotency, scopes, errors, retry schedule.
- Send webhooks — the webhook half of the Send side.
- Glossary · API reference · API operations