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
  }
}
CodeStatusWhat to do
domain_not_verified422Publish the DNS records and re-check
no_sending_environment422No sending environment is attached — contact us
connection_unverified422The attached environment failed verification — contact us
brand_not_provisioned422POST /v1/brands/{id}/provision
recipient_suppressed422Remove the suppression, or override it
message_rejected422Refused upstream — the reason field says why
rate_limited429Retryable. Back off and retry
sending_paused503Sending 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

POST/v1/brands

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
}
POST/v1/brands/{id}/provision

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"
}
POST/v1/domains

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" }]
}
The MAIL FROM MX target is region-specific. Pointing it at the wrong region silently breaks the return-path alignment DMARC needs, with no error anywhere.
POST/v1/domains/{id}/check

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"
}
POST/v1/domains/{id}/publish-dns

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" }
  ]
}
GET/v1/domains/{id}

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..." } }
  ]
}
POST/v1/domains/{id}/provision

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": [ "..." ]
}
POST/v1/mailboxes

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 with GET /v1/mailboxes; DELETE /v1/mailboxes/{id} removes the address but keeps the mail it already received.
GET/v1/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
}
GET/v1/inbox/{id}

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": []
}
Search with GET /v1/inbox/search?q= (add &folder= to scope it) — quoted phrases and -exclusions work as written. Mutate with POST /v1/inbox/{id}/folder, /star, /read. Download attachment bytes with GET /v1/inbox/{id}/attachments/{attachment_id} — always served as an opaque download, never inline, and refused outright when flagged by antivirus.
POST/v1/emails

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"
}
202 Accepted means the message was taken, not delivered. Terminal state arrives via events, usually within seconds. Pass "template" and "variables" instead of html/text to render a stored template. cc and bcc are optional; all recipients together are capped at 50 and every one counts against quota and send rate.
GET/v1/emails/{id}

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" }
  ]
}
GET/v1/emails

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"
}
GET/v1/suppressions

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..." }
  ]
}
POST/v1/templates

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" }
GET/v1/requests

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" }
  ]
}