Documentation
API reference
One base URL, bearer auth, JSON in and out. Every error tells you what to do about it.
Quickstart
Zero to a delivered email — and, if you want it, a readable inbox. Step 1 happens once per brand; steps 2–3 once per domain.
# 1. Create a brand and provision it. A brand is one isolated
# sending identity — most accounts start with exactly one.
curl -X POST https://api.mycompany.email/v1/brands \
-H "Authorization: Bearer $MYCO_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Acme Retail", "slug": "acme-retail"}'
curl -X POST https://api.mycompany.email/v1/brands/$BRAND/provision \
-H "Authorization: Bearer $MYCO_API_KEY"
# 2. Add a sending domain. Returns the four DNS records to publish —
# or publishes them for you when we manage the zone.
# DKIM is ONE record, under your own name.
curl -X POST https://api.mycompany.email/v1/domains \
-H "Authorization: Bearer $MYCO_API_KEY" -H "Content-Type: application/json" \
-d '{"brand_id": "'$BRAND'", "domain": "mail.acme.com"}'
# 3. Wait for green. Verification re-checks itself; poll if impatient —
# each record reports its live DNS state, so a typo is named.
curl -X POST https://api.mycompany.email/v1/domains/$DOMAIN/check \
-H "Authorization: Bearer $MYCO_API_KEY"
# 4. Send.
curl -X POST https://api.mycompany.email/v1/emails \
-H "Authorization: Bearer $MYCO_API_KEY" -H "Content-Type: application/json" \
-d '{"from": "hello@mail.acme.com", "to": "you@example.com",
"subject": "It works", "text": "Hello from mycompany.email"}'
# 5. Receive (optional). Claim a mailbox, point one MX at us,
# and read replies over the same API.
curl -X POST https://api.mycompany.email/v1/mailboxes \
-H "Authorization: Bearer $MYCO_API_KEY" -H "Content-Type: application/json" \
-d '{"domain_id": "'$DOMAIN'", "local_part": "support"}'
curl https://api.mycompany.email/v1/inbox \
-H "Authorization: Bearer $MYCO_API_KEY"Idempotency
Retries must never double-send. Pass an Idempotency-Key header (8–255 characters, any unique string) on any POST: the first request does the work, and a retry with the same key and body replays the stored response with idempotent-replay: true. The same key with a different body is refused — that is a bug worth hearing about, not a retry. Keys expire after 24 hours, and only successful responses are recorded, so a failed call can be retried with the same key.
curl -X POST https://api.mycompany.email/v1/emails \
-H "Authorization: Bearer $MYCO_API_KEY" \
-H "Idempotency-Key: order-8412-receipt" \
-H "Content-Type: application/json" \
-d '{"from": "...", "to": "...", "subject": "...", "text": "..."}'Authentication
Bearer token in the Authorization header. Nothing else — no query parameter fallback, because credentials in a URL leak into access logs, proxy logs and Referer headers.
curl https://api.mycompany.email/v1/emails \
-H "Authorization: Bearer mye_live_..."Keys are shown exactly once and stored as a SHA-256 digest — we cannot recover one for you. Each key carries scopes (email:send, domains:write, …) and can be pinned to a single brand, so a key issued for one client cannot send as another. Every response carries an x-request-id worth quoting in support.
Errors
Every error carries a machine-readable requirement — the action that resolves it — and a retryable flag, so a client can tell "wait and try again" apart from "this will never work until a human does something".
{
"error": {
"code": "domain_not_verified",
"message": "The sending domain mail.acme.com is not verified",
"requirement": "verify_domain",
"reason": "Verification status is \"PENDING\"",
"resolution": "Publish the DNS records, then POST /v1/domains/:id/check",
"retryable": false
}
}| Code | Status | What to do |
|---|---|---|
domain_not_verified | 422 | Publish the DNS records and re-check |
no_sending_environment | 422 | No sending environment is attached — contact us |
connection_unverified | 422 | The attached environment failed verification — contact us |
brand_not_provisioned | 422 | POST /v1/brands/{id}/provision |
recipient_suppressed | 422 | Remove the suppression, or override it |
message_rejected | 422 | Refused upstream — the reason field says why |
rate_limited | 429 | Retryable. Back off and retry |
sending_paused | 503 | Sending is paused upstream — we are on it |
Events
Delivery, bounce and complaint events flow from your sending environment into our ingestion queue within seconds. Events are attributed by their source channel against an allowlist — never by anything inside the payload, which is attacker-controllable.
A message moves forward only: a late DELIVERY cannot walk a bounced message back, and a complaint outranks a delivery because it arrives after it. Soft bounces mark delivery_delayed and do not suppress.
Reference
Create a brand
A brand is one isolated sending identity — the reputation boundary. Creating it is a database record; provisioning it creates the identity, event stream and reputation boundary in your sending environment.
Scope brands:write
Request
{ "name": "Acme Retail", "slug": "acme-retail" }Response
{
"id": "brand_d9b63ab8c30b4a65",
"name": "Acme Retail",
"slug": "acme-retail",
"status": "pending",
"identity_name": null
}Provision a brand
Creates the sending identity, event stream and associations in your environment. Idempotent — safe to re-run after a partial failure. Every call is serialised at one request per second — the rate the platform underneath throttles configuration changes to.
Scope brands:write
Response
{
"id": "brand_d9b63ab8c30b4a65",
"status": "ready",
"identity_name": "mycompany-email-acme-retail-3ab8c30b",
"configuration_set_name": "mycompany-email-acme-retail-3ab8c30b",
"provisioned_at": "2026-07-27T09: 12: 44.201Z"
}Add a sending domain
Registers the domain, mints its DKIM keypair (one TXT record, under your name — we hold and rotate the keys), configures the custom MAIL FROM subdomain, associates it with the brand's tenant, and returns the DNS to publish. When we manage the zone, the records are published for you.
Scope domains:write
Request
{
"brand_id": "brand_d9b63ab8c30b4a65",
"domain": "mail.acme.com",
"mail_from_subdomain": "bounce"
}Response
{
"id": "dom_7fc81d0e0dcf462e",
"domain": "mail.acme.com",
"verification_status": "pending",
"dkim_status": "pending",
"dns_branding": "branded",
"dns_records": [
{ "type": "TXT", "name": "mycompany._domainkey.mail.acme.com",
"value": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...", "purpose": "DKIM signing",
"status": "pending" },
{ "type": "MX", "name": "bounce.mail.acme.com", "priority": 10,
"value": "feedback-smtp.us-east-1.amazonses.com",
"purpose": "Custom MAIL FROM — return path alignment for DMARC",
"status": "pending" },
{ "type": "TXT", "name": "bounce.mail.acme.com",
"value": "v=spf1 include:spf.mycompany.email ~all",
"purpose": "SPF for the MAIL FROM domain", "status": "pending" },
{ "type": "TXT", "name": "_dmarc.mail.acme.com",
"value": "v=DMARC1; p=none; rua=mailto:dmarc@mail.acme.com",
"status": "not_checked" }
],
"dns_published": [{ "type": "TXT", "name": "mycompany._domainkey...", "action": "created" }]
}Re-check a domain
Polls the live verification, DKIM and MAIL FROM state and persists it. Cheap and safe to call on a timer while DNS propagates.
Scope domains:write
Response
{
"id": "dom_7fc81d0e0dcf462e",
"domain": "mail.acme.com",
"verification_status": "success",
"dkim_status": "success",
"mail_from_status": "success"
}Publish DNS to Cloudflare
Writes (or updates) the records into the Cloudflare zone. Idempotent — re-running after a DKIM key rotation converges rather than leaving stale records behind.
Scope domains:write
Response
{
"domain": "mail.acme.com",
"published": [
{ "type": "TXT", "name": "mycompany._domainkey...", "action": "created" },
{ "type": "MX", "name": "bounce.mail.acme.com", "action": "unchanged" }
]
}Retrieve a domain
The records table, any time — not just at creation. While the domain is unverified, each record also carries what public DNS actually says about it (dns.state: found, mismatch or missing, with a hint naming the likely mistake), and the response names the DNS host we detected from the domain's nameservers, with a link to its dashboard.
Scope domains:read
Response
{
"id": "dom_7fc81d0e0dcf462e",
"domain": "mail.acme.com",
"verification_status": "pending",
"dns_branding": "branded",
"dns_provider": { "provider": "Cloudflare",
"dashboard_url": "https://dash.cloudflare.com" },
"dns_records": [
{ "type": "TXT", "name": "mycompany._domainkey.mail.acme.com",
"value": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...", "status": "pending",
"dns": { "state": "missing",
"hint": "Not visible in public DNS yet. If you saved it more than ~10 minutes ago, check the NAME field..." } }
]
}Re-provision a domain
Re-runs provisioning for an existing domain — the recovery path when creation failed part-way, and the way to converge a domain after infrastructure changes. Idempotent: identities and keys that already exist are kept, never rotated.
Scope domains:write
Response
{
"id": "dom_7fc81d0e0dcf462e",
"verification_status": "pending",
"dns_branding": "branded",
"dns_records": [ "..." ]
}Create a mailbox
Claims an address on a verified domain — support@, or omit local_part for a catch-all that receives everything on the domain. The response includes the ONE MX record receiving needs; publish it and mail starts arriving. Mail to unclaimed addresses on your domains lands in the quarantine folder, never the void.
Scope inbox:write
Request
{
"domain_id": "dom_7fc81d0e0dcf462e",
"local_part": "support",
"display_name": "Acme Support"
}Response
{
"id": "mbx_402a70fa4b024f42",
"address": "support@mail.acme.com",
"catch_all": false,
"retention_days": 90,
"receiving_mx": {
"type": "MX", "name": "mail.acme.com", "priority": 10,
"value": "inbound-smtp.us-east-1.amazonaws.com",
"purpose": "Routes this domain's incoming mail to your inbox"
}
}List the inbox
A folder's messages, newest first, keyset-paginated so a busy inbox never shifts a page under you. Filter with ?folder= (inbox, spam, archive, trash, quarantine) and ?mailbox_id=; continue with ?cursor_at= and ?cursor_id= from next_cursor.
Scope inbox:read
Response
{
"data": [
{ "id": "inb_20313187d5254c2c", "from_address": "customer@example.com",
"subject": "Re: Your invoice", "snippet": "thanks — one question about...",
"read": false, "starred": false, "has_attachments": false,
"received_at": "2026-08-20T17: 55: 01.020Z" }
],
"next_cursor": null
}Open a message
The full message, ready to render: sanitized HTML (re-sanitized on every read, so a sanitizer upgrade protects old mail too), plain text, attachments, and — when the message answers something you sent through us — the original send's delivery timeline inline, event by event. Opening marks it read, the same gesture a UI makes.
Scope inbox:read
Response
{
"id": "inb_20313187d5254c2c",
"from_address": "customer@example.com",
"subject": "Re: Your invoice",
"text": "thanks — one question about the total...",
"html": "<p>thanks — one question...</p>",
"read": true,
"correlated_message_id": "msg_456e62e7421e4316",
"delivery_timeline": [
{ "type": "SEND", "at": "2026-08-20T17: 54: 32.483Z" },
{ "type": "DELIVERY", "at": "2026-08-20T17: 54: 32.934Z",
"detail": "54.240.8.43" }
],
"attachments": []
}Send an email
Resolves where to send from by the From domain — most specific wins: domain, then brand, then organization default. Checks your suppression list, respects the environment's real send rate, and attaches the brand's identity.
Scope email:send
Request
{
"from": "billing@mail.acme.com",
"to": ["customer@example.com"],
"cc": ["ap@example.com"],
"bcc": ["archive@mail.acme.com"],
"subject": "Your invoice",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"reply_to": "support@mail.acme.com",
"headers": { "X-Entity-Ref": "inv_1029" }
}Response
{
"id": "msg_456e62e7421e4316",
"provider_message_id": "0100019fa04e0539-7a285e21",
"status": "accepted",
"brand_id": "brand_d9b63ab8c30b4a65",
"routed_from": "organization",
"created_at": "2026-07-27T09: 14: 02.113Z"
}Retrieve a message
The message plus its full event history — send, delivery, bounce, complaint — with the sending IP and exact timestamps.
Scope email:read
Response
{
"id": "msg_456e62e7421e4316",
"status": "delivered",
"delivered_at": "2026-07-27T09: 14: 03.6Z",
"events": [
{ "type": "DELIVERY", "recipient": "customer@example.com",
"outgoing_ip": "54.240.8.43", "occurred_at": "2026-07-27T09: 14: 03.6Z" },
{ "type": "SEND", "occurred_at": "2026-07-27T09: 14: 02.6Z" }
]
}List messages
Cross-brand by default, newest first. Keyset-paginated with ?before= and ?limit= (max 200).
Scope email:read
Response
{
"data": [{ "id": "msg_456e...", "status": "delivered", "to": ["a@b.com"] }],
"next_before": "2026-07-27T09: 14: 02.113Z"
}Suppressions
Our list is the authority. Owning it end to end is the only way to answer "will this send?" deterministically. Add with POST, remove with DELETE /v1/suppressions/{email}, and test one address with GET /v1/suppressions/check?email=.
Scope suppressions:read
Response
{
"data": [
{ "email": "bounced@example.com", "reason": "BOUNCE",
"detail": "Permanent/General", "brand_id": "brand_d9b6..." }
]
}Templates
MJML for layout, Liquid for variables. Rendered on our side, so templates live in one place instead of being replicated across environments. Compiled at authoring time, so a broken template fails here rather than in front of your customer.
Scope templates:write
Request
{
"slug": "invoice",
"name": "Invoice",
"subject": "Invoice {{ number }} for {{ customer.name }}",
"mjml": "<mjml><mj-body><mj-section><mj-column><mj-text>Hi {{ customer.name }}</mj-text></mj-column></mj-section></mj-body></mjml>"
}Response
{ "id": "tpl_9f2a...", "slug": "invoice" }API request log
Every authenticated request with its status, duration and structured error code. Bodies are deliberately not stored — they contain recipient addresses and message content, and a debugging aid should not become a second copy of your mail.
Scope email:read
Response
{
"data": [
{ "request_id": "req_b8f4286...", "method": "POST", "path": "/v1/emails",
"status": 422, "duration_ms": 20,
"error_code": "domain_not_verified", "requirement": "add_domain" }
]
}