# emails.sh documentation

Transactional email over a REST API: one endpoint to send, domain verification in one screen, and delivery logs that say what happened.

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Every page below is also available on its own at https://emails.sh/docs/<slug>.md.

## Overview

https://emails.sh/docs

emails.sh sends transactional email over HTTPS. You POST a from address, a recipient, a subject, and a body to https://emails.sh/v1/emails, and you get back an id you can ask about later. That is the whole product surface for most people, and everything else here exists to support it.

A key made in the dashboard works immediately against onboarding@emails.sh, so you can send a real email before you have touched DNS. Sending from your own address takes one more step: add the domain, publish six DNS records, and verification finishes on its own.

The whole integration:
```bash
curl -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.com>",
    "to": ["ada@example.com"],
    "subject": "Your receipt from Acme",
    "html": "<p>Thanks for your order. Your receipt is attached.</p>",
    "text": "Thanks for your order. Your receipt is attached."
  }'
```

200 OK:
```json
{
  "id": "em_01J9X8Q2K7Y4RN3M",
  "status": "queued"
}
```

### Where to start

- **Quickstart** (/docs/quickstart): A key, a first send, and a verified domain. About five minutes, most of it DNS propagation.
- **Your language** (/docs/node): Node, Python, PHP, Ruby, Go, Rust, Java, .NET, Elixir, and curl. Complete programs, not fragments.
- **Your framework** (/docs/nextjs): Next.js, Laravel, Rails, Django, and a dozen more, each with the file to put the call in.
- **Send it to your coding assistant** (/docs/agents): llms.txt, every page as markdown, and a skill that integrates emails.sh without you reading any of this.

### What the API covers

- `POST /v1/emails` Send one email.
- `POST /v1/emails/batch` Send up to 100 in one request.
- `GET /v1/emails/:id` Status and delivery events for one email.
- `PATCH /v1/emails/:id` { send_at } moves a booked send to a new time. It keeps its id. 409 too_late_to_reschedule once it has gone.
- `GET /v1/emails` The delivery log. limit defaults to 25 and tops out at 100.
- `POST /v1/emails/:id/cancel` Call off an email booked with send_at, before it goes.
- `DELETE /v1/messages/scheduled/:id` The same cancel, by the older spelling. Still works.
- `GET /v1/domains` Domains, with the records a pending one still needs.
- `POST /v1/domains` { domain } returns every DNS record to publish.
- `GET /v1/domains/:id` One domain, with the records it still needs if it is pending.
- `POST /v1/domains/:id/verify` Check the records now and report which are missing.
- `PATCH /v1/domains/:id` { tracking_host } sets the hostname in front of tracked links. null goes back to the shared one.
- `DELETE /v1/domains/:id` Remove a domain. DELETE /v1/domains?id= is the older spelling and still works.

Beyond those: /v1/api-keys to make and revoke keys, /v1/webhooks to receive delivery events, /v1/audiences for the small non-transactional case, and an inbound side at /v1/messages for replies to the mail you send. The full list is in the API reference.

Machine-readable versions of these pages: any doc URL with .md on the end for the markdown, /llms.txt for the whole API in one file, /openapi.json for the OpenAPI 3.1 description.

## Quickstart

https://emails.sh/docs/quickstart

Three steps. The first two take about a minute and prove the integration works. The third is DNS, and it is the only part you cannot finish in this tab.

### 1. Get a key

Sign up at https://emails.sh/signup, open https://emails.sh/dashboard/api-keys, and create a key. It starts with esh_ and is shown once. Put it in your environment rather than in a source file.

Where the key goes:
```bash
# .env, and check that .env is in .gitignore.
# The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here

# or, for a one-off in a shell
export EMAILSSH_API_KEY="esh_your_key_here"
```

### 2. Send an email

Before any DNS exists you can send from onboarding@emails.sh, to an address you own. The sandbox address only sends to addresses that belong to the workspace, which is what keeps it from being an open relay.

First send:
```bash
curl -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "onboarding@emails.sh",
    "to": ["you@example.com"],
    "subject": "First email from emails.sh",
    "html": "<p>It works.</p>"
  }'
```

200 OK:
```json
{
  "id": "em_01J9X8Q2K7Y4RN3M",
  "status": "queued"
}
```

Keep the id. GET /v1/emails/em_01J9X8Q2K7Y4RN3M tells you whether the receiving server took it, and if it bounced, what the remote server said.

### 3. Send from your own domain

Add a subdomain you control rather than the domain your own mail runs on: mail.acme.com, not acme.com. A subdomain keeps a sending reputation apart from your human email and lets you publish an MX record without competing with your existing inbox.

Add the domain:
```bash
curl -X POST https://emails.sh/v1/domains \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain": "mail.acme.com"}'
```

201 Created:
```json
{
  "id": "dom_01J9X8W1A2",
  "domain": "mail.acme.com",
  "verification_status": "pending",
  "records": [
    { "type": "CNAME", "name": "abc123._domainkey.mail.acme.com", "value": "abc123.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "CNAME", "name": "def456._domainkey.mail.acme.com", "value": "def456.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "CNAME", "name": "ghi789._domainkey.mail.acme.com", "value": "ghi789.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "TXT", "name": "_emailssh.mail.acme.com", "value": "emailssh-verify=9f2c4e1b", "purpose": "ownership" },
    { "type": "TXT", "name": "mail.acme.com", "value": "v=spf1 include:amazonses.com ~all", "purpose": "SPF" },
    { "type": "TXT", "name": "_dmarc.mail.acme.com", "value": "v=DMARC1; p=none;", "purpose": "DMARC" }
  ]
}
```

Publish those records at your DNS host, then call POST /v1/domains/dom_01J9X8W1A2/verify. It reads DNS and answers with found: true or false per record, so a missing one is named rather than guessed at. Most hosts propagate in under fifteen minutes.

Once verification passes, change from to an address on that domain and send again. Nothing else in your code changes.

To have a coding assistant do all of this in your codebase instead, run npx skills add emailssh/skill and ask it to add emails.sh. It picks the SDK for your framework, writes the key into the right env file, writes the send call, and reads the DNS records back to you.

### Next

- **The send endpoint in full** (/docs/sending): Every field, attachments, scheduling, idempotency, and tags.
- **Delivery events** (/docs/delivery): Statuses, the event timeline, and what a bounce tells you.
- **Webhooks** (/docs/webhooks): Get delivered, bounced, and complained pushed to you instead of polling.
- **Errors** (/docs/errors): Every code the API returns, and what to do about each.

## API keys and authentication

https://emails.sh/docs/api-keys

Every request carries Authorization: Bearer esh_..., and nothing else. There is no signing, no client id, and no session. A key belongs to one workspace and can do anything that workspace can do unless you narrow it with scopes.

Any authenticated call:
```bash
curl https://emails.sh/v1/domains \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

### Making keys

Create them at https://emails.sh/dashboard/api-keys, or over the API when a deploy pipeline needs its own. The value is in the create response and nowhere else afterwards: we store a hash, so a lost key is replaced rather than recovered.

A key that can only send:
```bash
curl -X POST https://emails.sh/v1/api-keys \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "production web", "scopes": ["mail:send"]}'
```

#### `api_keys.list`

`{ }`

Keys on the workspace, with the last time each was used. Values are never listed.

Returns: { api_keys: ApiKey[] }

#### `api_keys.create`

`{ name: string, scopes?: string[] }`

Create a key. The value is returned once and never again.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |
| scopes | `string[]` | no |

Returns: { id, name, key }

#### `api_keys.revoke`

`{ id: string }`

Revoke a key. It stops working on the next request, with no grace period.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { deleted: id }

### Scopes

| Scope | What it allows |
| --- | --- |
| mail:send | POST /v1/emails and /v1/emails/batch, and reading the status of what it sent. |
| mail:read | Reading received mail at /v1/messages, /v1/threads, and /v1/search. |
| workspace | Domains, webhooks, audiences, and other keys. |

A key with no scopes listed gets all of them. Give the key in your web app mail:send only: an attacker with it can send mail as you, which is bad, but cannot read your inbound mail or repoint your webhooks, which is worse.

### Storing the key

- **Server side only**: A key in browser JavaScript is a key anybody can read. Send from your server, your API route, or your edge function, never from the client.
- **Environment, not source**: Put it in .env, .env.local, or your platform's secret store, and confirm that file is gitignored before you write it.
- **One key per environment**: Separate keys for local, staging, and production means revoking one does not take the others down, and the last-used column tells you which is which.

### Rotating

Create the new key, deploy it, confirm traffic on the new key in the dashboard, then revoke the old one. Revocation takes effect on the next request with no grace period, so doing it in the other order causes an outage.

If a key ever appears in a commit, a log line, or a chat message, revoke it rather than deciding it was probably fine. Revoking takes five seconds and creating a replacement takes five more.

### Rate limits

600 requests a minute per key, which is 10 a second, across every endpoint. The budget is per API key rather than per workspace, so a workspace with several keys gets more, never less. Over it you get 429 with retry-after in seconds. Responses carry x-ratelimit-limit and, where we can compute it cheaply, x-ratelimit-remaining and x-ratelimit-reset.

## Sending domains

https://emails.sh/docs/domains

Until a domain is verified, the only address you can send from is onboarding@emails.sh, and that one only reaches addresses on your own workspace. Verifying a domain is what turns emails.sh into something your customers see.

### Use a subdomain

Add mail.acme.com or send.acme.com rather than acme.com. Three reasons, and all of them matter later: a subdomain builds its own sending reputation, so a bad week of transactional mail does not follow your sales team into their inbox; you can publish an MX record on it for replies without competing with the mail host serving acme.com; and you can hand it a stricter DMARC policy than the parent domain is ready for.

### Add it

POST /v1/domains:
```bash
curl -X POST https://emails.sh/v1/domains \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain": "mail.acme.com"}'
```

201 Created:
```json
{
  "id": "dom_01J9X8W1A2",
  "domain": "mail.acme.com",
  "verification_status": "pending",
  "records": [
    { "type": "CNAME", "name": "abc123._domainkey.mail.acme.com", "value": "abc123.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "CNAME", "name": "def456._domainkey.mail.acme.com", "value": "def456.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "CNAME", "name": "ghi789._domainkey.mail.acme.com", "value": "ghi789.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "TXT", "name": "_emailssh.mail.acme.com", "value": "emailssh-verify=9f2c4e1b", "purpose": "ownership" },
    { "type": "TXT", "name": "mail.acme.com", "value": "v=spf1 include:amazonses.com ~all", "purpose": "SPF" },
    { "type": "TXT", "name": "_dmarc.mail.acme.com", "value": "v=DMARC1; p=none;", "purpose": "DMARC" }
  ]
}
```

### The records

| Record | Why it is there |
| --- | --- |
| CNAME ..._domainkey | Three of them, one per DKIM key. They let a receiving server check that the message body was signed by us and not altered on the way. Publish all three: rotation moves between them. |
| TXT _emailssh.<domain> | Proves you control the domain. Nothing sends until this resolves. |
| TXT <domain> (SPF) | v=spf1 include:amazonses.com ~all. If the domain already has an SPF record, add include:amazonses.com to the existing one rather than publishing a second: two SPF records is a hard failure, not a merge. |
| TXT _dmarc.<domain> | v=DMARC1; p=none; to start. It tells receivers what to do when DKIM and SPF disagree, and turns on the reports you need before you tighten it. |
| MX <domain> | Only if you want to receive replies at this domain. Optional, and on an apex domain it competes with whatever already serves your mail. |

### Verify

Publish the records, then ask for a check. Verification also runs nightly on its own, so a domain left alone finishes eventually, but the call is instant and tells you exactly which record has not landed.

Check now:
```bash
curl -X POST https://emails.sh/v1/domains/dom_01J9X8W1A2/verify \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

One record still missing:
```json
{
  "domain": "mail.acme.com",
  "verified": false,
  "dkim_status": "pending",
  "records": [
    { "type": "TXT", "name": "_emailssh.mail.acme.com", "value": "emailssh-verify=9f2c4e1b", "purpose": "ownership", "found": true },
    { "type": "TXT", "name": "mail.acme.com", "value": "v=spf1 include:amazonses.com ~all", "purpose": "SPF", "found": false }
  ]
}
```

found: false on a record you published usually means the DNS host appended the zone to a name that was already absolute. If you entered _emailssh.mail.acme.com and your host shows _emailssh.mail.acme.com.mail.acme.com, enter just _emailssh instead.

### One domain, by its id

The id in the create response addresses the domain on its own, so nothing has to filter the list to find one row. GET /v1/domains/:id returns it, PATCH /v1/domains/:id changes the one setting it has, and DELETE /v1/domains/:id removes it. The older DELETE /v1/domains?id= spelling still works and runs the same code, so anything already written against it keeps working; the path form is the one to write now, and it is the URL an SDK or a coding assistant reaches for first.

GET, PATCH, and DELETE /v1/domains/:id:
```bash
# One domain, with the records still to publish if it is pending
curl https://emails.sh/v1/domains/dom_01J9X8W1A2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Put your own hostname in front of tracked links
curl -X PATCH https://emails.sh/v1/domains/dom_01J9X8W1A2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tracking_host": "links.mail.acme.com"}'

# Back to the shared tracking host
curl -X PATCH https://emails.sh/v1/domains/dom_01J9X8W1A2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tracking_host": null}'

# Remove it
curl -X DELETE https://emails.sh/v1/domains/dom_01J9X8W1A2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

tracking_host is the only setting a domain has, and PATCH is a second door onto POST /v1/domains/:id/tracking rather than a second implementation of it: the host is validated, the CNAME is checked live, and the change is refused with 422 tracking_cname_not_found until the record resolves. A bare label is expanded under the domain, so "links" on mail.acme.com means links.mail.acme.com. Send null to go back to the shared host. See /docs/tracking-domain for the record itself.

A body carrying open_tracking or click_tracking is refused by name with 422 tracking_switch_not_supported rather than accepted and dropped. There is no such switch here: opens are reported per send and links are only rewritten on a broadcast that asked for it. A setting that reports success and changes nothing is worse than no setting, because it ends up on somebody's list of reasons to believe tracking is off.

DELETE answers 409 domain_in_use when sending addresses are still on the domain, and error.mailboxes carries the count of them, because removing the domain would take their mail with it. Remove those first. The shared workspace subdomain is not deletable through the API at all and answers 404, which is the same answer an id belonging to another workspace gets.

#### `domains.list`

`{ }`

Every sending domain on the workspace, with the DNS records a pending one still needs.

Returns: { domains: Domain[] }

#### `domains.create`

`{ domain: string }`

Add a sending domain. The response carries every DNS record to publish, so setup can finish without opening the dashboard.

| Parameter | Type | Required |
| --- | --- | --- |
| domain | `string` | yes |

Returns: { id, domain, verification_status, records[] }

#### `domains.get`

`{ id: string }`

One domain, in the shape the list gives it, with the DNS records still to publish if it is pending. An id from another workspace reads as missing.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Domain

#### `domains.verify`

`{ id: string }`

Check the records now rather than waiting for the nightly pass. Safe to call repeatedly, and the answer says which records are still missing.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { domain, verified, records: (Record & { found })[] }

#### `domains.delete`

`{ id: string }`

Remove a domain. Anything still sending from it starts failing, so move senders first.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { deleted: id }

### After it verifies

Change the from address in your code and send. Then move DMARC from p=none to p=quarantine once a week of reports shows your own mail passing, and warm up gradually rather than moving a hundred thousand messages a day onto a domain that sent nothing yesterday.

A domain.verified webhook fires the moment verification completes, which is the event to wait on if a deploy script is doing this without a person watching.

## Send an email

https://emails.sh/docs/sending

One request sends one email. It returns as soon as the message is accepted and queued, which takes a few tens of milliseconds, and delivery happens after that. If you need to know it landed, read /docs/delivery or subscribe to a webhook rather than blocking on the response.

The status code tells you which of the two things happened. A send that goes now answers 200 with status "queued". A send booked with send_at answers 202 with status "scheduled". Branch on the status field rather than on the code if you only care that it was accepted.

- `POST /v1/emails` Send one email.

Every field at once:
```json
{
  "from": "Acme <hello@acme.com>",
  "to": ["ada@example.com"],
  "cc": [],
  "bcc": [],
  "subject": "Your receipt from Acme",
  "html": "<p>Thanks for your order.</p>",
  "text": "Thanks for your order.",
  "reply_to": "support@acme.com",
  "headers": { "List-Unsubscribe": "<https://acme.com/unsubscribe/abc>" },
  "attachments": [
    { "filename": "receipt.pdf", "content_type": "application/pdf", "content_base64": "JVBERi0xLjQK" }
  ],
  "tags": { "campaign": "receipt", "user_id": "u_8812" },
  "send_at": "in 2 hours",
  "idempotency_key": "order-8812-receipt"
}
```

200 OK:
```json
{
  "id": "em_01J9X8Q2K7Y4RN3M",
  "status": "queued"
}
```

#### `emails.send`

`{ from: string, to: string[], subject: string, html?: string, text?: string, cc?: string[], bcc?: string[], reply_to?: string | string[], headers?: Record<string, string>, attachments?: { filename, content_base64, content_type? }[], tags?: Record<string, string>, send_at?: string, idempotency_key?: string }`

Send one email. The endpoint every integration starts with, and the only one many ever use. 200 when the status is queued, 202 when it is scheduled.

| Parameter | Type | Required |
| --- | --- | --- |
| from | `string` | yes |
| to | `string[]` | yes |
| subject | `string` | yes |
| html | `string` | no |
| text | `string` | no |
| cc | `string[]` | no |
| bcc | `string[]` | no |
| reply_to | `string | string[]` | no |
| headers | `Record<string, string>` | no |
| attachments | `{ filename, content_base64, content_type? }[]` | no |
| tags | `Record<string, string>` | no |
| send_at | `string` | no |
| idempotency_key | `string` | no |

Returns: { id, status: "queued" | "scheduled" }

### The from address

from is either an address or a display name and address: "Acme <hello@acme.com>". The domain has to be verified on the workspace, or the send is refused with invalid_from_domain and a message naming the domain. onboarding@emails.sh works without any DNS and reaches addresses on your own workspace, which is the address to use while you are wiring things up.

Pick a local part a person can reply to. no-reply@ is a habit rather than a requirement, and reply_to costs nothing: point it at the support inbox you already read.

### Bodies

Send html, text, or both. When both are present they go as a multipart alternative and the receiving client picks. Sending html alone means we generate the text part from your markup, which is usually worse than the one you would have written: mail clients that show the text part exist, and so do spam filters that compare the two.

Inline your CSS. Gmail strips a <style> block in some contexts, Outlook ignores most of what it does not strip, and a stylesheet URL is never fetched. Keep the markup to tables and inline styles if the layout matters, and test it before you assume.

### Recipients

Up to 50 addresses in total, counted across to, cc, and bcc together. A fifty-first anywhere in those three fields is 400 too_many_recipients. This is a transactional API: if you find yourself splitting a list into groups of fifty, you want /v1/emails/batch, which sends up to 100 separate emails in one request and gives each recipient their own copy. For a real list with subscription state, you want /docs/broadcasts.

An address that hard bounced or filed a complaint goes on the workspace suppression list, and later sends to it are refused with recipient_suppressed rather than silently dropped. That refusal is deliberate: continuing to mail an address that bounced is the fastest way to lose a sending reputation.

### Testing without sending

Four reserved addresses on emails.sh produce a real send record and put no mail on the internet. Name one as a recipient and the send is intercepted before it reaches AWS, so a test suite can run two hundred times without mailing a colleague or a throwaway inbox nobody reads.

| Address | What the send does |
| --- | --- |
| delivered@emails.sh | Accepted. Produces email.sent, then email.delivered. |
| bounced@emails.sh | Accepted. Produces email.sent, then email.bounced, and suppresses the address, so the next send to it is refused with recipient_suppressed. |
| complained@emails.sh | Accepted. Produces email.sent, email.delivered, then email.complained, and suppresses the address. |
| suppressed@emails.sh | Refused on the first send with the ordinary 422 recipient_suppressed, so you can exercise that branch without waiting for a real bounce. |

Sub-addressing works on all four: send to delivered+run-42@emails.sh and the label comes back on the message, so a test suite running in parallel can tell its own sends apart in the delivery log.

A message that names both a test address and a real one is refused with 400 mixed_test_and_real_recipients, and nothing is sent to anybody. A send is either a test or it is real. Without that rule, "I am testing" and "I am mailing a stranger" would be the same request.

What a test send produces is real. You get a message id you can GET /v1/emails/:id, the delivery events the address name implies, and webhook deliveries through the ordinary queue with ordinary signatures, so your handler cannot tell the difference. Every webhook body carries "test_mode": true in data, and GET /v1/emails/:id returns a test_mode boolean on every message.

What it does not touch is anything describing your reputation or your bill: no monthly allowance, no daily cap, no usage or billing counter, no analytics rollup, and no bounce or complaint rate. A test bounce is not evidence about a real recipient, so it is not counted as one. Test sends are rate limited like any other request.

These are recipient addresses. onboarding@emails.sh is the separate sandbox sender a new workspace can send from before it has verified a domain, and it is unrelated: mail from it really goes out.

### Attachments

Base64 with no data: prefix, up to 40 MB in total per message counted after base64 encoding rather than on disk. Base64 inflates a file by about a third, so a 30 MB file is already 40 MB encoded. The whole assembled message shares that 40 MB: if the attachments each fit but the finished message does not, the send is refused with 413 message_too_large. Give a content_type when you know it, since a receiving client that has to guess from the filename often guesses wrong.

Attach a file from disk:
```ts
import { readFile } from 'node:fs/promises';

const pdf = await readFile('./receipt.pdf');

await fetch('https://emails.sh/v1/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    from: 'Acme <hello@acme.com>',
    to: ['ada@example.com'],
    subject: 'Your receipt',
    html: '<p>Your receipt is attached.</p>',
    attachments: [
      {
        filename: 'receipt.pdf',
        content_type: 'application/pdf',
        content_base64: pdf.toString('base64')
      }
    ]
  })
});
```

### Scheduling

send_at takes an ISO 8601 timestamp such as 2026-08-04T09:00:00Z, a relative offset such as "in 1 min", "in 2 hours" or "in 3 days", or a clock time such as "tomorrow at 9am", "today at 17:30" or "friday at 3pm", up to 30 days ahead. A clock time with no offset on it is read as UTC, because there is no caller timezone on the wire to read instead. Two things are refused rather than guessed at: a named timezone such as "3pm ET", because the abbreviation is ambiguous between zones and across daylight saving, and "next tuesday", because it means the coming Tuesday to some people and the one after to others. Say "tuesday" for the next one, or send an ISO 8601 timestamp with the offset in it. The response is 202 with status "scheduled" and the same id shape, and DELETE /v1/messages/scheduled/:id cancels it any time before it leaves. A time we cannot parse confidently is send_at_invalid, one in the past is send_at_in_past, and one past the 30 day ceiling is send_at_too_far.

Book it for later:
```bash
curl -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.com>",
    "to": ["ada@example.com"],
    "subject": "Your trial ends tomorrow",
    "html": "<p>Your trial ends tomorrow.</p>",
    "send_at": "tomorrow at 9am"
  }'
```

A booking can be moved. PATCH /v1/emails/:id with a new send_at reschedules it, and the booking keeps the id you were given, so whatever you stored still names it. Cancelling and rebooking was the only way to do this before, and it handed back a different id in the field callers keep, so the next cancel answered 404 a long way from the code that caused it. send_at is the only field this endpoint reads, and it is parsed by the same code the send uses, so a time POST /v1/emails would refuse is refused here with the same code and the same sentence.

PATCH /v1/emails/:id:
```bash
curl -X PATCH https://emails.sh/v1/emails/9f2c1b4e-3d5a-4c7e-8b21-0d6f5a3c1e88 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"send_at": "tomorrow at 9am"}'
```

200 OK:
```json
{
  "id": "9f2c1b4e-3d5a-4c7e-8b21-0d6f5a3c1e88",
  "status": "scheduled",
  "scheduled_at": "2026-08-02T09:00:00.000Z"
}
```

Once the message is on its way the answer is 409 too_late_to_reschedule, naming the status it reached, and not a 404: the booking was real and the request was reasonable, the send had simply already been claimed. An id that was never a booking on this workspace, including one that belongs to somebody else, is a 404. GET /v1/messages/scheduled lists what is still waiting. Rescheduling takes the mail:send scope, the same one the send took.

### Idempotency

Give idempotency_key your own id for the send, usually derived from the thing that caused it: order-8812-receipt, user-441-password-reset-1753440000. A repeat with the same key within 24 hours returns the original result and sends nothing, and says so with idempotent-replay: true in the response headers. That turns a retried webhook, a double-clicked button, or a queue that redelivered into one email instead of two.

The Idempotency-Key request header does the same job, and it wins over the body field when both are present. That is the case a client library's automatic retry hits, since most of them set the header on their own.

The same key with a different body is a bug rather than a retry, so it comes back as 409 idempotency_key_reused instead of quietly sending the old message. A repeat that arrives while the first is still being processed is 409 idempotency_in_flight, which is the one case worth retrying: send the identical request again in a second or two.

### Tags

tags is a flat map of strings stored with the email and echoed on every webhook it produces. Put the things you will want to filter logs by: the template name, the tenant, the user id. They never appear in the message a recipient sees.

### Headers

headers passes extra fields through verbatim. The one worth setting on anything a person might want to stop is List-Unsubscribe, together with List-Unsubscribe-Post: gmail and yahoo both want a one-click unsubscribe on bulk mail, and a receipt with one costs you nothing.

We set Message-ID, Date, MIME-Version, and the DKIM signature ourselves, and passing your own is refused with 400 reserved_header rather than silently overwritten. Message-ID in particular is the id every delivery event, webhook, and log row for the message is keyed on, so it cannot be supplied. In-Reply-To and References are the exception and are accepted: see Threading a reply below.

### Threading a reply

Set In-Reply-To to the Message-ID of the message you are answering, and References to the chain, both in headers and both in angle brackets. That is the standard mechanism and it is what a recipient's mail client keys on to show the exchange as one conversation.

POST /v1/emails:
```json
{
  "from": "Acme <support@acme.com>",
  "to": ["someone@example.com"],
  "subject": "Re: your order",
  "text": "Shipped this morning.",
  "headers": {
    "In-Reply-To": "<b8e1f0c2-4d3a-4e5b-9c7d-1f2a3b4c5d6e@acme.com>",
    "References": "<b8e1f0c2-4d3a-4e5b-9c7d-1f2a3b4c5d6e@acme.com>"
  }
}
```

When the ids name a message on this workspace, whether you sent it or received it, the send is filed onto that conversation as well, so GET /v1/threads/:id returns the whole exchange in order and the headers are written for you off the message we found. When they name a conversation that started somewhere other than emails.sh, the headers go out on the wire exactly as you gave them and nothing else changes. Either way the send is accepted: an id we do not recognise is not an error.

### When it is refused

A refusal is a 4xx with a code, a sentence saying what happened, and a next step in prose. The prose is there because an agent reads it and acts on it, and because a stack trace three months from now is a worse place to learn what invalid_from_domain meant.

422 Unprocessable Entity:
```json
{
  "error": {
    "code": "invalid_from_domain",
    "message": "the domain acme.com is not verified on this workspace",
    "next": "Verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while you are testing."
  }
}
```

Every code is at /docs/errors.

## Batch sending

https://emails.sh/docs/batch

POST /v1/emails/batch takes an array of the exact bodies POST /v1/emails takes, up to 100 of them. Each entry is its own email to its own recipient: nobody sees anybody else's address, and one bad entry does not stop the rest.

- `POST /v1/emails/batch` Send up to 100 in one request.

Two emails, one request:
```bash
curl -X POST https://emails.sh/v1/emails/batch \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "emails": [
      {
        "from": "Acme <hello@acme.com>",
        "to": ["ada@example.com"],
        "subject": "Your invite to Acme",
        "html": "<p>Hello Ada, here is your invite.</p>"
      },
      {
        "from": "Acme <hello@acme.com>",
        "to": ["grace@example.com"],
        "subject": "Your invite to Acme",
        "html": "<p>Hello Grace, here is your invite.</p>"
      }
    ]
  }'
```

207 Multi-Status:
```json
{
  "data": [
    { "id": "em_01J9X8Q2K7Y4RN3M", "status": "queued" },
    { "id": null, "status": "failed", "error": { "code": "recipient_suppressed", "message": "grace@example.com hard bounced on 2026-07-02 and is suppressed on this workspace", "next": "Remove it at https://emails.sh/dashboard/suppressions if you know it is good." } }
  ]
}
```

The array comes back in the order you sent it, one entry per input, so index 3 in the response is index 3 in the request. A refused entry has id: null and an error object saying why. Walk it and record the ids; an entry with an error never sent and will not retry itself.

#### `emails.batch`

`{ emails: Send[] }`

Send up to 100 emails in one request. Each entry succeeds or fails on its own, and the response keeps the order you sent them in.

| Parameter | Type | Required |
| --- | --- | --- |
| emails | `Send[]` | yes |

Returns: { data: ({ id, status } | { id: null, status: "failed", error })[] }

### What batch is not

It is not a mailing list, and it does not template. Every entry carries its own rendered subject and body, so personalisation happens in your code before the call. If you want a list with subscription state, see /docs/audiences.

The whole request counts as its entries against your quota, so 100 emails in one batch and 100 sent one at a time draw down the same allowance. What batch saves is round trips, which matters when you are sending from a serverless function with a wall-clock budget.

idempotency_key works per entry, not per batch. Give each entry its own key and a redelivered queue message re-sends nothing.

## Delivery and status

https://emails.sh/docs/delivery

A send returns an id. GET /v1/emails/:id turns that id into the answer to "did it arrive", without a support ticket and without a log grep.

- `GET /v1/emails/:id` Status and delivery events for one email.

Ask about one email:
```bash
curl https://emails.sh/v1/emails/em_01J9X8Q2K7Y4RN3M \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "id": "em_01J9X8Q2K7Y4RN3M",
  "status": "delivered",
  "from": "Acme <hello@acme.com>",
  "to": ["ada@example.com"],
  "subject": "Your receipt from Acme",
  "tags": { "campaign": "receipt" },
  "test_mode": false,
  "created_at": "2026-07-28T09:14:01.882Z",
  "events": [
    { "type": "queued", "at": "2026-07-28T09:14:01.882Z" },
    { "type": "sent", "at": "2026-07-28T09:14:02.117Z" },
    { "type": "delivered", "at": "2026-07-28T09:14:04.903Z" }
  ]
}
```

test_mode is on every response. It is true when the email went to one of the reserved test addresses and therefore never left our servers, and false otherwise. It is never absent, because "did a person receive this" cannot be answered by a field that is sometimes missing. See /docs/sending.

### Statuses

| Status | What it means |
| --- | --- |
| queued | Accepted and waiting to go out. Normal for a second or two, and for as long as you asked if you set send_at. |
| scheduled | Booked for a send_at in the future. Cancellable until it leaves. |
| sent | Handed to the receiving mail server and accepted by it. This is the last thing SMTP tells us synchronously. |
| delivered | The receiving server confirmed it took the message. As close to "it landed" as email gets: it does not mean anybody read it, and it does not rule out a spam folder. |
| bounced | Refused. The event carries the remote server's reason and whether it was permanent. |
| complained | The recipient pressed the spam button. The address is suppressed automatically. |
| canceled | A scheduled email was cancelled before it went. |

### Reading a bounce

A permanent bounce:
```json
{
  "type": "bounced",
  "at": "2026-07-28T09:14:06.220Z",
  "bounce_type": "permanent",
  "bounce_subtype": "no_such_mailbox",
  "diagnostic": "smtp; 550 5.1.1 <ada@example.com>: Recipient address rejected: User unknown"
}
```

- **permanent**: The address does not exist or will never accept mail. It is suppressed on the workspace, and retrying is what gets a sender blocked. Fix the address, do not loop.
- **transient**: A full mailbox, a greylist, or a server having a bad afternoon. We retry these for you. Nothing to do.
- **complaint**: A spam report, which arrives hours or days later through a feedback loop. The address is suppressed and should stay that way.

### Do not poll

Polling this endpoint in a loop is a way to spend your 600 requests a minute on nothing. Delivery takes seconds to minutes and complaints take days, so subscribe to webhooks and let the events come to you. GET /v1/emails/:id is for the moment somebody asks about one specific message.

#### `emails.get`

`{ id: string }`

Status and delivery events for one email: when it was accepted, when the receiving server took it, and the bounce or complaint if there was one.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { id, status, to, subject, created_at, events[] }

Message bodies are retained for 30 days and the event timeline for 12 months. After that the id still resolves and the body does not.

## Receiving replies

https://emails.sh/docs/receiving

Most transactional email goes out from an address nobody watches, and every reply to it is lost. emails.sh can receive on a domain you verified, so a customer answering your receipt reaches you rather than a bounce.

This is the second half of the product and not the first. If all you need is to send, skip this page entirely.

### Turn it on

Publish the MX record listed with your domain's other records: MX mail.acme.com pointing at inbound-smtp.eu-west-1.amazonaws.com with priority 10. Only publish it on a subdomain that has no other mail on it, since an MX record decides where all mail for that name goes.

Once it resolves, mail to any address on that domain is stored and available over the API. There is no per-address setup: support@mail.acme.com and receipts@mail.acme.com both arrive.

### Get it pushed to you

Register a webhook for the email.received event and you get the message as JSON the moment it lands, signed, with the body and the attachment list. That is the shape most applications want: no polling, no cron.

Subscribe to inbound mail:
```bash
curl -X POST https://emails.sh/v1/webhooks \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/hooks/emails",
    "events": ["email.received"]
  }'
```

### Or read it

- `GET /v1/messages` ?unread_only=true&thread_id=&limit= over received mail.
- `GET /v1/messages/:id` One received message in full.
- `POST /v1/messages/:id/reply-all` { body } answers on the same thread.
- `POST /v1/messages/:id/forward` { to, body?, mode? }
- `POST /v1/messages/:id/archive` { archived?, unread? }
- `GET /v1/messages/:id/attachments/:filename` One attachment, as its own bytes.
- `GET /v1/threads` Conversations, newest first.
- `GET /v1/threads/:id` Every message in one conversation.
- `GET /v1/search` ?q= ranked full-text search over received mail.

#### `messages.list`

`{ unread_only?: boolean, thread_id?: string, limit?: number }`

Mail that arrived at an address on a domain of yours with inbound turned on.

| Parameter | Type | Required |
| --- | --- | --- |
| unread_only | `boolean` | no |
| thread_id | `string` | no |
| limit | `number` | no |

Returns: { messages: Message[] }

#### `messages.get`

`{ id: string }`

One received message with its full body, headers, and attachment list.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Message

#### `messages.reply`

`{ id: string, html?: string, text?: string }`

Answer a received message on its own thread, with References and In-Reply-To set for you.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| html | `string` | no |
| text | `string` | no |

Returns: { id, status }

#### `threads.get`

`{ id: string }`

Every message in one conversation, oldest first.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Thread

A reply is threaded to the message it answers, so GET /v1/threads/:id returns the whole exchange in order. Answering with POST /v1/messages/:id/reply-all sets In-Reply-To and References for you, which is what keeps the conversation in one place in the recipient's client.

Inbound mail is not a mailbox with a password. There is no IMAP and no webmail: it is an API surface, and the dashboard shows it read-only.

## Integrating with a coding assistant

https://emails.sh/docs/agents

Most people wiring up email now are doing it through Cursor, Claude Code, Windsurf, or something similar. Those assistants do not read a documentation site the way you do: they fetch a URL, take what parses, and write code from it. So we publish the version they want rather than hoping they scrape the version you want.

### The fastest path

Paste this into your assistant. It is one line, it names the file to read, and it is enough to get a working integration in most codebases.

Paste this to your assistant:
```text
Add transactional email to this project using emails.sh.
Read https://emails.sh/llms.txt first, then integrate it: install the right
client for this stack, put EMAILSSH_API_KEY in the env file, and write the
send call where the app needs it. Ask me for the API key when you need it.
```

### llms.txt

https://emails.sh/llms.txt is the whole API in one file: authentication, every endpoint, a complete send example, the error catalogue with what to do about each code, the domain verification flow, and the rate limits. It is written so that an assistant which fetches only that file can integrate emails.sh correctly without fetching anything else.

The whole API, one fetch:
```bash
curl https://emails.sh/llms.txt
```

### Every page as markdown

Add .md to any documentation URL and you get the same page as markdown, with the code samples in fenced blocks and no navigation around them. /docs.md is all of it in one document.

The machine-readable surface:
```bash
curl https://emails.sh/docs/sending.md     # one page
curl https://emails.sh/docs.md             # every page
curl https://emails.sh/openapi.json        # OpenAPI 3.1
```

### The skill

A skill is a set of instructions your assistant loads when the task matches. Ours knows how to integrate emails.sh into a codebase: it detects the framework, picks the client, writes the key into the right env file and checks that file is gitignored, writes the send call in the right place, and reads you the DNS records at the end.

Install it once, in the project root:
```bash
npx skills add emailssh/skill
```

Then ask for what you want: "send a verification email when someone signs up". The skill covers Claude Code, Cursor, Copilot, Windsurf, Cline, and the rest of the clients the skills CLI supports.

### Why the errors read the way they do

Every refusal carries a sentence of prose and a link, not just a code. An assistant reads that sentence and acts on it, which is the difference between an integration that finishes and one that stops with a 422 in the terminal.

A refusal an agent can act on:
```json
{
  "error": {
    "code": "invalid_from_domain",
    "message": "the domain acme.com is not verified on this workspace",
    "next": "Verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while you are testing."
  }
}
```

### And the MCP server

The skill is knowledge. The MCP server is the ability to act: it lets the same assistant operate your emails.sh account, so adding a domain, reading back the DNS records, and checking why a message bounced all happen in the editor. See /docs/mcp.

Nothing here writes anybody's email. The assistant integrates the API; what you send is your code and your words.

## MCP server

https://emails.sh/docs/mcp

The MCP server is developer tooling. It gives the assistant already writing your code a set of tools for the things you would otherwise alt-tab to a dashboard for: adding a sending domain, reading the exact DNS rows to publish, triggering verification, making a key, looking at webhook deliveries, and asking why a message bounced.

It is a thin client over the REST API on the rest of this site. Every tool is one or two calls you could make with curl, which is deliberate: there is no second implementation of sending, validation, or quota to drift from the first.

This is not a way to give an AI agent a mailbox. It operates your account. What your application sends is still POST /v1/emails from your own code.

### Connect it

The endpoint is https://mcp.emails.sh and speaks Streamable HTTP. Authenticate with an esh_ key from https://emails.sh/dashboard/api-keys, or authorize over OAuth in a client that supports it, in which case no key is written to disk.

Claude Code:
```bash
claude mcp add --transport http emailssh https://mcp.emails.sh
```

.cursor/mcp.json, or .vscode/mcp.json, or .mcp.json:
```json
{
  "mcpServers": {
    "emailssh": {
      "type": "http",
      "url": "https://mcp.emails.sh",
      // The key comes from https://emails.sh/dashboard/api-keys.
      "headers": { "Authorization": "Bearer esh_your_key_here" }
    }
  }
}
```

Cursor reads .cursor/mcp.json, VS Code reads .vscode/mcp.json, and Claude Code reads .mcp.json in the project root. Restart the client after writing the file: a server added to a config a running client already read does not appear until it does.

The key comes from https://emails.sh/dashboard/api-keys and is shown once. Put it in the file your client reads, confirm that file is gitignored, and give it the narrowest scope that lets it do the job.

### The tools

| Tool | What it does |
| --- | --- |
| send_email | Sends one message, the same way POST /v1/emails does. Sending to an address that is not yours is something to be asked about first. |
| get_email | Status and the delivery timeline for one id, in prose: when it was accepted, when the receiving server took it, and the bounce if there was one. |
| list_emails | Recent sends, filterable by status and by tag. |
| list_domains | Sending domains and where each one is in verification. |
| add_domain | Adds a domain and returns the records to publish. |
| get_domain_records | The exact DNS rows for a domain, as type, name, and value, ready to paste into a DNS host. |
| verify_domain | Checks the records now and says which ones have not landed. |
| list_api_keys | Keys and when each was last used. Values are never returned. |
| create_api_key | Makes a key. The value is in the result and nowhere else. |
| revoke_api_key | Revokes one, effective on the next request. |
| list_webhooks | Endpoints and the events each is subscribed to. |
| create_webhook | Registers an endpoint and returns the signing secret once. |
| get_webhook_deliveries | What each attempt got back: status code, response body, duration. This is what tells a broken endpoint apart from a missing event. |
| test_webhook | Sends a test event now and reports exactly what your endpoint answered. |
| replay_webhook_delivery | Sends a stored delivery again, under its original id, so a receiver that deduplicates recognises the repeat. |
| why_did_this_bounce | Reads the delivery log for a message or an address and explains the refusal, including the remote server's own diagnostic. |
| list_suppressions | Addresses blocked on this workspace, and why each one is there. |
| remove_suppression | Clears one, when you know the address is good again. |
| check_domain_health | SPF, DKIM, and DMARC as we resolve them right now, rather than as they were meant to be published. |
| search_docs | Searches this documentation, so the assistant answers from the reference rather than from memory. |

Every result comes back as prose an assistant can act on rather than a JSON dump, for the same reason the API errors do: the next thing that happens is a decision, and a decision needs a sentence.

### Scopes and safety

- **The key decides what the tools can reach**: A key scoped to mail:send exposes the sending tools and nothing else. The domain, key, webhook, and suppression tools need the workspace scope, so a restricted key cannot call them at all rather than calling them and failing.
- **Destructive tools say so first**: Revoking a key, removing a suppression, and sending to an address that is not yours all announce what they are about to do. Approve them the way you approve any other tool call.
- **Nothing is exclusive to MCP**: Every tool maps onto endpoints documented at /docs/rest. If a tool is refused, the same call over HTTPS is refused for the same reason, and /docs/errors explains it.
- **It does not read your mail**: The server operates the account. Received mail is behind /v1/messages and is not part of this tool set.

### What it is good for

The pattern worth knowing: ask for the integration and the setup in one sentence. "Add email to the signup flow and get mail.acme.com verified." The assistant writes the send with the skill, then uses add_domain and get_domain_records to hand you the DNS rows, and verify_domain to tell you when they resolved. Nothing in that loop needs a browser except your DNS host.

The other one is debugging. "The receipt to ada@example.com never arrived" turns into get_email, then why_did_this_bounce, then list_suppressions, and ends with a sentence rather than a dashboard tour.

send_email takes the same body as POST /v1/emails, so an assistant can test a stored template with template: { id, variables } and file a send under a topic with topic: "product-updates". Templates are managed at /v1/templates and topics at /v1/topics; see /docs/templates and /docs/topics.

## curl

https://emails.sh/docs/curl

Nothing here needs a client library. If you can make an HTTPS request, you can send email, and curl is the shortest way to prove a key works before you write any code.

### Send

POST /v1/emails:
```bash
curl -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.com>",
    "to": ["ada@example.com"],
    "subject": "Your receipt from Acme",
    "html": "<p>Thanks for your order. Your receipt is attached.</p>",
    "text": "Thanks for your order. Your receipt is attached."
  }'
```

200 OK:
```json
{
  "id": "em_01J9X8Q2K7Y4RN3M",
  "status": "queued"
}
```

The key comes from https://emails.sh/dashboard/api-keys and lives in the environment, not in the command: a key typed inline ends up in your shell history and in the terminal scrollback you paste into an issue.

### Read the result

Send, then check:
```bash
# Send and keep the id
ID=$(curl -s -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"from":"onboarding@emails.sh","to":["you@example.com"],"subject":"Test","text":"Hello."}' \
  | jq -r .id)

# Ask what happened to it
curl -s https://emails.sh/v1/emails/$ID \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" | jq .status
```

### From a file

A body with real HTML in it is easier to keep in a file than to quote in a shell. -d @file reads it, and the file can be generated by anything.

Body in a file:
```bash
cat > email.json <<'JSON'
{
  "from": "Acme <hello@acme.com>",
  "to": ["ada@example.com"],
  "subject": "Your receipt from Acme",
  "html": "<p>Thanks for your order.</p>",
  "text": "Thanks for your order."
}
JSON

curl -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d @email.json
```

### Attach a file

Base64 into the body with jq:
```bash
PDF=$(base64 -w 0 receipt.pdf)   # macOS: base64 -i receipt.pdf

curl -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg pdf "$PDF" '{
    from: "Acme <hello@acme.com>",
    to: ["ada@example.com"],
    subject: "Your receipt",
    html: "<p>Your receipt is attached.</p>",
    attachments: [{ filename: "receipt.pdf", content_type: "application/pdf", content_base64: $pdf }]
  }')"
```

### Fail loudly in a script

curl exits 0 on a 422 by default, which is how a broken deploy script stays quiet for a week. --fail-with-body gives you a non-zero exit and still prints the reason.

A send that a CI job notices:
```bash
set -euo pipefail

curl --fail-with-body -sS -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"from":"onboarding@emails.sh","to":["you@example.com"],"subject":"Deploy","text":"Shipped."}'
```

## Node and TypeScript

https://emails.sh/docs/node

### Install

Node 18 or newer:
```bash
npm install @emails.sh/sdk
```

### Send

send.ts:
```ts
import { Emailssh } from '@emails.sh/sdk';

// The key comes from https://emails.sh/dashboard/api-keys and lives in
// EMAILSSH_API_KEY in your .env. Never in a file you commit.
const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });

const { id } = await mail.send({
  from: 'Acme <hello@acme.com>',
  to: ['ada@example.com'],
  subject: 'Your receipt from Acme',
  html: '<p>Thanks for your order.</p>',
  text: 'Thanks for your order.'
});

console.log('queued', id);
```

The constructor takes an options object with the key in it, and falls back to EMAILSSH_API_KEY when you leave apiKey out. Every method returns a promise and throws on a refusal, with the code and the message on the error.

### Handle a refusal

Errors carry a code and a sentence:
```ts
import { Emailssh, EmailsshError } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });

try {
  const { id } = await mail.send({
    from: 'Acme <hello@acme.com>',
    to: ['ada@example.com'],
    subject: 'Your receipt from Acme',
    html: '<p>Thanks for your order.</p>'
  });
  console.log('queued', id);
} catch (err) {
  if (err instanceof EmailsshError) {
    // err.code is the machine-readable reason, err.nextStep says what to do
    // about it in prose, and err.status is the HTTP status.
    console.error(err.code, err.message, err.nextStep);
    if (err.code === 'rate_limited') {
      // err.retryAfter is seconds. Wait it out rather than retrying harder.
    }
  } else {
    throw err;
  }
}
```

### Without the SDK

The API is one POST. On Node 18 and newer, and on every edge runtime, fetch is built in and this is the whole integration.

lib/email.ts, no dependencies:
```ts
export async function sendEmail(to: string, subject: string, html: string) {
  const res = await fetch('https://emails.sh/v1/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ from: 'Acme <hello@acme.com>', to: [to], subject, html })
  });

  if (!res.ok) {
    const { error } = (await res.json()) as { error: { code: string; message: string; next?: string } };
    // next says what to do about it, in prose. Keep it: it is the part a
    // person, or an assistant, reads three months from now.
    throw new Error(`emails.sh refused the send: ${error.code}: ${error.message} ${error.next ?? ''}`);
  }

  const { id } = (await res.json()) as { id: string };
  return id;
}
```

### Types

The package ships its own types, so the send body is checked at compile time and an unknown field is an error rather than a silently ignored key.

Typed send bodies:
```ts
import type { SendEmail, SentEmail } from '@emails.sh/sdk';

export function receipt(orderId: string, to: string): SendEmail {
  return {
    from: 'Acme <hello@acme.com>',
    to: [to],
    subject: `Receipt for order ${orderId}`,
    html: `<p>Thanks for order ${orderId}.</p>`,
    tags: { template: 'receipt', order_id: orderId },
    idempotencyKey: `receipt-${orderId}`
  };
}

export function logResult(sent: SentEmail) {
  console.log(sent.id, sent.status);
}
```

Framework-specific placement, including which file the call belongs in: /docs/nextjs, /docs/sveltekit, /docs/remix, /docs/nuxt, /docs/astro, /docs/express, /docs/hono, /docs/cloudflare-workers.

## Python

https://emails.sh/docs/python

### Install

Python 3.9 or newer:
```bash
pip install emailssh
```

### Send

send.py:
```py
import os
from emailssh import Emailssh

# The key comes from https://emails.sh/dashboard/api-keys and lives in
# EMAILSSH_API_KEY in your environment or .env file.
mail = Emailssh(api_key=os.environ["EMAILSSH_API_KEY"])

sent = mail.send(
    from_="Acme <hello@acme.com>",
    to=["ada@example.com"],
    subject="Your receipt from Acme",
    html="<p>Thanks for your order.</p>",
    text="Thanks for your order.",
)

print("queued", sent["id"])
```

The argument is from_ with a trailing underscore, because from is a keyword in Python. It goes over the wire as from, and every other field is named exactly as the API names it. to takes a string or a list.

### Handle a refusal

Errors carry a code and a sentence:
```py
import os
from emailssh import Emailssh, EmailsshError

mail = Emailssh(api_key=os.environ["EMAILSSH_API_KEY"])

try:
    sent = mail.send(
        from_="Acme <hello@acme.com>",
        to=["ada@example.com"],
        subject="Your receipt from Acme",
        html="<p>Thanks for your order.</p>",
    )
    print("queued", sent["id"])
except EmailsshError as err:
    # err.code is the machine-readable reason, err.next_step says what to do
    # about it in prose, and err.status is the HTTP status.
    print(err.code, err, err.next_step)
    if err.code == "rate_limited":
        pass  # err.retry_after is seconds. Wait, do not retry harder.
```

### Standard library only

No dependency, no install step, and it runs anywhere Python does. This is the version to paste into a Lambda that already has enough in its bundle.

email.py, no dependencies:
```py
import json
import os
import urllib.error
import urllib.request


def send_email(to: str, subject: str, html: str) -> str:
    body = json.dumps({
        "from": "Acme <hello@acme.com>",
        "to": [to],
        "subject": subject,
        "html": html,
    }).encode()

    request = urllib.request.Request(
        "https://emails.sh/v1/emails",
        data=body,
        headers={
            "Authorization": f"Bearer {os.environ['EMAILSSH_API_KEY']}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            return json.load(response)["id"]
    except urllib.error.HTTPError as err:
        refusal = json.loads(err.read())["error"]
        raise RuntimeError(f"{refusal['code']}: {refusal['message']} {refusal.get('next', '')}") from err


if __name__ == "__main__":
    print(send_email("ada@example.com", "Your receipt", "<p>Thanks.</p>"))
```

### With requests

If requests is already in the project:
```py
import os
import requests

response = requests.post(
    "https://emails.sh/v1/emails",
    headers={"Authorization": f"Bearer {os.environ['EMAILSSH_API_KEY']}"},
    json={
        "from": "Acme <hello@acme.com>",
        "to": ["ada@example.com"],
        "subject": "Your receipt from Acme",
        "html": "<p>Thanks for your order.</p>",
    },
    timeout=10,
)
response.raise_for_status()
print(response.json()["id"])
```

Where the call belongs in a web app: /docs/django, /docs/flask, /docs/fastapi.

## PHP

https://emails.sh/docs/php

There is no emails.sh PHP package to install: the API is a single JSON POST, and the ext-curl that ships with every PHP build covers it. If your project already has Guzzle, the second example is shorter.

### With cURL

src/Email.php:
```php
<?php
// src/Email.php
// The key comes from https://emails.sh/dashboard/api-keys and is read from the
// environment. Put it in .env and load it however your app already does.

function send_email(string $to, string $subject, string $html): string
{
    $payload = json_encode([
        'from' => 'Acme <hello@acme.com>',
        'to' => [$to],
        'subject' => $subject,
        'html' => $html,
    ], JSON_THROW_ON_ERROR);

    $ch = curl_init('https://emails.sh/v1/emails');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $payload,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('EMAILSSH_API_KEY'),
            'Content-Type: application/json',
        ],
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('emails.sh unreachable: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    if ($status >= 400) {
        $refusal = $result['error'];
        throw new RuntimeException($refusal['code'] . ': ' . $refusal['message'] . ' ' . ($refusal['next'] ?? ''));
    }

    return $result['id'];
}
```

send.php:
```php
<?php
require __DIR__ . '/src/Email.php';

$id = send_email('ada@example.com', 'Your receipt from Acme', '<p>Thanks for your order.</p>');
echo "queued {$id}\n";
```

### With Guzzle

If it is not already there:
```bash
composer require guzzlehttp/guzzle
```

send.php with Guzzle:
```php
<?php
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;

$client = new Client(['base_uri' => 'https://emails.sh', 'timeout' => 10]);

try {
    $response = $client->post('/v1/emails', [
        'headers' => ['Authorization' => 'Bearer ' . getenv('EMAILSSH_API_KEY')],
        'json' => [
            'from' => 'Acme <hello@acme.com>',
            'to' => ['ada@example.com'],
            'subject' => 'Your receipt from Acme',
            'html' => '<p>Thanks for your order.</p>',
        ],
    ]);

    $result = json_decode((string) $response->getBody(), true);
    echo "queued {$result['id']}\n";
} catch (ClientException $e) {
    $refusal = json_decode((string) $e->getResponse()->getBody(), true)['error'];
    // next says what to do about it, so log it rather than the class name.
    fwrite(STDERR, $refusal['code'] . ': ' . $refusal['message'] . ' ' . ($refusal['next'] ?? '') . "\n");
}
```

Inside a Laravel application, use the mail driver instead: /docs/laravel.

## Ruby

https://emails.sh/docs/ruby

There is no emails.sh gem to install. net/http is in the standard library and the API is one POST, so this file is the entire integration.

### Send

lib/emails.rb:
```rb
# lib/emails.rb
require 'net/http'
require 'json'
require 'uri'

# The key comes from https://emails.sh/dashboard/api-keys. Keep it in
# ENV['EMAILSSH_API_KEY'], loaded from .env or from Rails credentials.
module Emailssh
  ENDPOINT = URI('https://emails.sh/v1/emails').freeze

  class Refused < StandardError
    attr_reader :code

    def initialize(code, message)
      @code = code
      super("#{code}: #{message}")
    end
  end

  def self.send_email(to:, subject:, html:, from: 'Acme <hello@acme.com>')
    request = Net::HTTP::Post.new(ENDPOINT)
    request['Authorization'] = "Bearer #{ENV.fetch('EMAILSSH_API_KEY')}"
    request['Content-Type'] = 'application/json'
    request.body = JSON.generate(from: from, to: [to], subject: subject, html: html)

    response = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, read_timeout: 10) do |http|
      http.request(request)
    end

    body = JSON.parse(response.body)
    unless response.is_a?(Net::HTTPSuccess)
      refusal = body['error']
      raise Refused.new(refusal['code'], "#{refusal['message']} #{refusal['next']}")
    end

    body['id']
  end
end
```

send.rb:
```rb
require_relative 'lib/emails'

begin
  id = Emailssh.send_email(
    to: 'ada@example.com',
    subject: 'Your receipt from Acme',
    html: '<p>Thanks for your order.</p>'
  )
  puts "queued #{id}"
rescue Emailssh::Refused => e
  # e.message carries the sentence saying what to do next.
  warn e.message
end
```

### With Faraday

Or add it to your Gemfile:
```bash
gem install faraday
```

send.rb with Faraday:
```rb
require 'faraday'
require 'json'

conn = Faraday.new(url: 'https://emails.sh') do |f|
  f.request :json
  f.response :json
  f.options.timeout = 10
end

response = conn.post('/v1/emails') do |req|
  req.headers['Authorization'] = "Bearer #{ENV.fetch('EMAILSSH_API_KEY')}"
  req.body = {
    from: 'Acme <hello@acme.com>',
    to: ['ada@example.com'],
    subject: 'Your receipt from Acme',
    html: '<p>Thanks for your order.</p>'
  }
end

if response.success?
  puts "queued #{response.body['id']}"
else
  refusal = response.body['error']
  warn "#{refusal['code']}: #{refusal['message']} #{refusal['next']}"
end
```

In a Rails app, wire it as an ActionMailer delivery method instead: /docs/rails.

## Go

https://emails.sh/docs/go

There is no emails.sh Go module to add. net/http and encoding/json cover the whole API, and the program below compiles as it stands.

### Send

email/email.go:
```go
// email/email.go
package email

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

type Send struct {
	From           string            `json:"from"`
	To             []string          `json:"to"`
	Subject        string            `json:"subject"`
	HTML           string            `json:"html,omitempty"`
	Text           string            `json:"text,omitempty"`
	Tags           map[string]string `json:"tags,omitempty"`
	IdempotencyKey string            `json:"idempotency_key,omitempty"`
}

type Result struct {
	ID     string `json:"id"`
	Status string `json:"status"`
}

// Refusal is what the API returns instead of an id: {"error": {"code",
// "message", "next"}}. Code is machine readable, Next says what to do about it.
type Refusal struct {
	Body struct {
		Code    string `json:"code"`
		Message string `json:"message"`
		Next    string `json:"next"`
	} `json:"error"`
}

func (r *Refusal) Error() string {
	return r.Body.Code + ": " + r.Body.Message + " " + r.Body.Next
}

var client = &http.Client{Timeout: 10 * time.Second}

// SendEmail posts one email. The key comes from
// https://emails.sh/dashboard/api-keys via EMAILSSH_API_KEY.
func SendEmail(ctx context.Context, send Send) (Result, error) {
	body, err := json.Marshal(send)
	if err != nil {
		return Result{}, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://emails.sh/v1/emails", bytes.NewReader(body))
	if err != nil {
		return Result{}, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("EMAILSSH_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := client.Do(req)
	if err != nil {
		return Result{}, fmt.Errorf("emails.sh unreachable: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		refusal := &Refusal{}
		if err := json.NewDecoder(res.Body).Decode(refusal); err != nil {
			return Result{}, fmt.Errorf("emails.sh returned %d", res.StatusCode)
		}
		return Result{}, refusal
	}

	result := Result{}
	if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
		return Result{}, err
	}
	return result, nil
}
```

main.go:
```go
// main.go
package main

import (
	"context"
	"errors"
	"log"

	"example.com/app/email"
)

func main() {
	result, err := email.SendEmail(context.Background(), email.Send{
		From:    "Acme <hello@acme.com>",
		To:      []string{"ada@example.com"},
		Subject: "Your receipt from Acme",
		HTML:    "<p>Thanks for your order.</p>",
	})

	refusal := &email.Refusal{}
	switch {
	case errors.As(err, &refusal):
		log.Fatalf("refused: %s", refusal)
	case err != nil:
		log.Fatal(err)
	}

	log.Printf("queued %s (%s)", result.ID, result.Status)
}
```

### If you would rather use a client library

Any HTTP client works, since there is nothing to negotiate beyond a bearer token. resty, for example:

Optional:
```bash
go get github.com/go-resty/resty/v2
```

main.go with resty:
```go
package main

import (
	"log"
	"os"

	"github.com/go-resty/resty/v2"
)

func main() {
	var result struct {
		ID     string `json:"id"`
		Status string `json:"status"`
	}

	res, err := resty.New().R().
		SetAuthToken(os.Getenv("EMAILSSH_API_KEY")).
		SetHeader("Content-Type", "application/json").
		SetBody(map[string]any{
			"from":    "Acme <hello@acme.com>",
			"to":      []string{"ada@example.com"},
			"subject": "Your receipt from Acme",
			"html":    "<p>Thanks for your order.</p>",
		}).
		SetResult(&result).
		Post("https://emails.sh/v1/emails")

	if err != nil {
		log.Fatal(err)
	}
	if res.IsError() {
		log.Fatalf("refused: %s", res.String())
	}
	log.Printf("queued %s", result.ID)
}
```

## Rust

https://emails.sh/docs/rust

There is no emails.sh crate. reqwest and serde are what almost every Rust service already uses for HTTP and JSON, and the program below is complete.

### Dependencies

Cargo.toml:
```text
# Cargo.toml
[package]
name = "acme-email"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```

### Send

src/main.rs:
```text
// src/main.rs
use serde::{Deserialize, Serialize};
use std::env;

#[derive(Serialize)]
struct Send<'a> {
    from: &'a str,
    to: Vec<&'a str>,
    subject: &'a str,
    html: &'a str,
}

#[derive(Deserialize)]
struct Queued {
    id: String,
    status: String,
}

#[derive(Deserialize)]
struct Refusal {
    error: RefusalBody,
}

#[derive(Deserialize)]
struct RefusalBody {
    code: String,
    message: String,
    next: Option<String>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The key comes from https://emails.sh/dashboard/api-keys.
    let key = env::var("EMAILSSH_API_KEY").expect("EMAILSSH_API_KEY is not set");

    let response = reqwest::Client::new()
        .post("https://emails.sh/v1/emails")
        .bearer_auth(key)
        .json(&Send {
            from: "Acme <hello@acme.com>",
            to: vec!["ada@example.com"],
            subject: "Your receipt from Acme",
            html: "<p>Thanks for your order.</p>",
        })
        .send()
        .await?;

    if response.status().is_client_error() || response.status().is_server_error() {
        let refusal: Refusal = response.json().await?;
        // next says what to do about it, in prose.
        eprintln!(
            "refused: {}: {} {}",
            refusal.error.code,
            refusal.error.message,
            refusal.error.next.unwrap_or_default()
        );
        std::process::exit(1);
    }

    let queued: Queued = response.json().await?;
    println!("queued {} ({})", queued.id, queued.status);
    Ok(())
}
```

### Blocking, without an async runtime

src/main.rs, blocking:
```text
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json", "rustls-tls"] }
use serde_json::json;
use std::env;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let key = env::var("EMAILSSH_API_KEY")?;

    let response = reqwest::blocking::Client::new()
        .post("https://emails.sh/v1/emails")
        .bearer_auth(key)
        .json(&json!({
            "from": "Acme <hello@acme.com>",
            "to": ["ada@example.com"],
            "subject": "Your receipt from Acme",
            "html": "<p>Thanks for your order.</p>"
        }))
        .send()?;

    println!("{}", response.text()?);
    Ok(())
}
```

## Java

https://emails.sh/docs/java

There is no emails.sh artifact to add to your build. The HTTP client in the JDK since 11 is enough, and this class compiles with javac and nothing else.

### Send

Email.java:
```text
// src/main/java/com/acme/Email.java
package com.acme;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public final class Email {
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    /** The key comes from https://emails.sh/dashboard/api-keys, via EMAILSSH_API_KEY. */
    private static final String KEY = System.getenv("EMAILSSH_API_KEY");

    public static String send(String to, String subject, String html) throws Exception {
        String body = """
                {
                  "from": "Acme <hello@acme.com>",
                  "to": ["%s"],
                  "subject": "%s",
                  "html": "%s"
                }
                """.formatted(escape(to), escape(subject), escape(html));

        HttpRequest request = HttpRequest.newBuilder(URI.create("https://emails.sh/v1/emails"))
                .header("Authorization", "Bearer " + KEY)
                .header("Content-Type", "application/json")
                .timeout(Duration.ofSeconds(10))
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();

        HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() >= 400) {
            // The body carries an error code and a message saying what to do
            // about it, so keep the whole thing in the exception.
            throw new IllegalStateException("emails.sh refused the send: " + response.body());
        }
        return response.body();
    }

    private static String escape(String value) {
        return value.replace("\\", "\\\\").replace("\"", "\\\"");
    }

    public static void main(String[] args) throws Exception {
        System.out.println(send("ada@example.com", "Your receipt from Acme", "<p>Thanks for your order.</p>"));
    }
}
```

### With Jackson, if you already have it

Building JSON by hand stops being reasonable as soon as a body has user input in it. If Jackson is on the classpath, serialise a map instead.

Serialising the body properly:
```text
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;

private static final ObjectMapper MAPPER = new ObjectMapper();

String body = MAPPER.writeValueAsString(Map.of(
        "from", "Acme <hello@acme.com>",
        "to", List.of(to),
        "subject", subject,
        "html", html,
        "idempotency_key", "receipt-" + orderId));
```

In a Spring Boot application, wire it as a bean: /docs/spring.

## C# and .NET

https://emails.sh/docs/dotnet

There is no emails.sh NuGet package. HttpClient covers the API, and the class below is what you register in the DI container of a web app or call directly from a console program.

### Send

EmailClient.cs:
```text
// Email/EmailClient.cs
using System.Net.Http.Json;
using System.Text.Json.Serialization;

namespace Acme;

public record SendEmail(
    [property: JsonPropertyName("from")] string From,
    [property: JsonPropertyName("to")] string[] To,
    [property: JsonPropertyName("subject")] string Subject,
    [property: JsonPropertyName("html")] string Html);

public record Queued(
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("status")] string Status);

public record RefusalBody(
    [property: JsonPropertyName("code")] string Code,
    [property: JsonPropertyName("message")] string Message,
    [property: JsonPropertyName("next")] string? Next);

public record Refusal([property: JsonPropertyName("error")] RefusalBody Error);

public class EmailRefusedException(Refusal refusal)
    : Exception($"{refusal.Error.Code}: {refusal.Error.Message} {refusal.Error.Next}")
{
    public string Code { get; } = refusal.Error.Code;
}

public class EmailClient(HttpClient http)
{
    public async Task<Queued> SendAsync(SendEmail email, CancellationToken ct = default)
    {
        var response = await http.PostAsJsonAsync("/v1/emails", email, ct);

        if (!response.IsSuccessStatusCode)
        {
            var refusal = await response.Content.ReadFromJsonAsync<Refusal>(ct)
                          ?? new Refusal(new RefusalBody("unknown", $"HTTP {(int)response.StatusCode}", null));
            throw new EmailRefusedException(refusal);
        }

        return (await response.Content.ReadFromJsonAsync<Queued>(ct))!;
    }
}
```

### Register it

Program.cs:
```text
// Program.cs
using Acme;

var builder = WebApplication.CreateBuilder(args);

// The key comes from https://emails.sh/dashboard/api-keys. In development put
// it in user secrets (dotnet user-secrets set "Emailssh:ApiKey" "esh_...");
// in production it is an environment variable, EMAILSSH_API_KEY.
var apiKey = builder.Configuration["Emailssh:ApiKey"]
             ?? Environment.GetEnvironmentVariable("EMAILSSH_API_KEY")
             ?? throw new InvalidOperationException("No emails.sh API key configured");

builder.Services.AddHttpClient<EmailClient>(client =>
{
    client.BaseAddress = new Uri("https://emails.sh");
    client.Timeout = TimeSpan.FromSeconds(10);
    client.DefaultRequestHeaders.Authorization = new("Bearer", apiKey);
});

var app = builder.Build();

app.MapPost("/signup", async (EmailClient emails, string address) =>
{
    var queued = await emails.SendAsync(new SendEmail(
        From: "Acme <hello@acme.com>",
        To: [address],
        Subject: "Welcome to Acme",
        Html: "<p>Confirm your address to finish signing up.</p>"));

    return Results.Ok(new { queued.Id });
});

app.Run();
```

AddHttpClient gives you one pooled HttpClient with the header already on it. Constructing a new HttpClient per send exhausts sockets under load, and it is the usual cause of a service that sends fine for an hour and then stops.

## Elixir

https://emails.sh/docs/elixir

There is no emails.sh hex package. Req is the HTTP client most Elixir projects reach for, and the module below is the whole integration.

### Dependency

mix.exs, then mix deps.get:
```text
# mix.exs
defp deps do
  [
    {:req, "~> 0.5"}
  ]
end
```

### Send

lib/acme/email.ex:
```text
# lib/acme/email.ex
defmodule Acme.Email do
  @moduledoc """
  Transactional email over emails.sh.

  The key comes from https://emails.sh/dashboard/api-keys and is read from the
  EMAILSSH_API_KEY environment variable at runtime, never compiled in.
  """

  @endpoint "https://emails.sh/v1/emails"

  @spec send(keyword()) :: {:ok, String.t()} | {:error, String.t()}
  def send(opts) do
    body = %{
      from: Keyword.get(opts, :from, "Acme <hello@acme.com>"),
      to: [Keyword.fetch!(opts, :to)],
      subject: Keyword.fetch!(opts, :subject),
      html: Keyword.fetch!(opts, :html)
    }

    case Req.post(@endpoint, json: body, auth: {:bearer, key()}, receive_timeout: 10_000) do
      {:ok, %{status: status, body: %{"id" => id}}} when status < 400 ->
        {:ok, id}

      {:ok, %{body: %{"error" => %{"code" => code, "message" => message} = refusal}}} ->
        # next says what to do about it, so keep it rather than the code alone.
        {:error, "#{code}: #{message} #{refusal["next"]}"}

      {:error, reason} ->
        {:error, "emails.sh unreachable: #{inspect(reason)}"}
    end
  end

  defp key do
    System.fetch_env!("EMAILSSH_API_KEY")
  end
end
```

In iex:
```text
iex> Acme.Email.send(to: "ada@example.com", subject: "Your receipt from Acme", html: "<p>Thanks.</p>")
{:ok, "em_01J9X8Q2K7Y4RN3M"}
```

### Off the request path

A send takes tens of milliseconds, but it is still a network call in front of a user. Task.Supervisor keeps it off the response, and a crash in the task does not take the caller with it.

Supervised, fire and forget:
```text
# In your application supervision tree
children = [
  {Task.Supervisor, name: Acme.TaskSupervisor}
]

# At the call site
Task.Supervisor.start_child(Acme.TaskSupervisor, fn ->
  Acme.Email.send(
    to: user.email,
    subject: "Welcome to Acme",
    html: "<p>Confirm your address to finish signing up.</p>"
  )
end)
```

In Phoenix, wire it as a Swoosh adapter or call it from a context: /docs/phoenix.

## Next.js

https://emails.sh/docs/nextjs

The only rule that matters in Next.js is that the send happens on the server. A key in a client component is a key in the JavaScript bundle, and anybody can read it. Server actions, route handlers, and server components are all fine; "use client" files are not.

### Install

App Router, Next 14 or 15:
```bash
npm install @emails.sh/sdk
```

.env.local:
```bash
# .env.local, which create-next-app already gitignores.
# The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
```

Do not prefix it with NEXT_PUBLIC_. That prefix is what pushes a variable into the browser bundle, which is the one thing this key must never be in.

### One client for the app

lib/emails.ts:
```ts
// lib/emails.ts
import 'server-only';
import { Emailssh } from '@emails.sh/sdk';

if (!process.env.EMAILSSH_API_KEY) {
  throw new Error('EMAILSSH_API_KEY is not set. Add it to .env.local.');
}

export const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });
```

The server-only import turns an accidental import from a client component into a build error rather than a leaked key at runtime. Install it with npm install server-only.

### A server action

app/actions/subscribe.ts:
```ts
// app/actions/subscribe.ts
'use server';

import { mail } from '@/lib/emails';

export async function subscribe(formData: FormData) {
  const address = String(formData.get('email') ?? '');
  if (!address.includes('@')) return { error: 'That does not look like an email address.' };

  const { id } = await mail.send({
    from: 'Acme <hello@acme.com>',
    to: [address],
    subject: 'Confirm your subscription',
    html: '<p>Click the link in this email to confirm.</p>',
    text: 'Click the link in this email to confirm.',
    idempotencyKey: `subscribe-${address}`
  });

  return { id };
}
```

### Or a route handler

app/api/send/route.ts:
```ts
// app/api/send/route.ts
import { NextResponse } from 'next/server';
import { mail } from '@/lib/emails';

export async function POST(request: Request) {
  const { to, subject, html } = await request.json();

  try {
    const { id } = await mail.send({ from: 'Acme <hello@acme.com>', to: [to], subject, html });
    return NextResponse.json({ id });
  } catch (err) {
    // The message says what to do about it. Log it; do not show it to the user.
    console.error('emails.sh refused the send', err);
    return NextResponse.json({ error: 'Could not send that email.' }, { status: 502 });
  }
}
```

On Vercel, set EMAILSSH_API_KEY in the project's environment variables for every environment you deploy, then redeploy. A variable added without a redeploy is not in the running build.

## Nuxt

https://emails.sh/docs/nuxt

Nuxt splits configuration into public and private. Anything under runtimeConfig without a public key stays on the server, which is exactly what an API key needs.

### Install

Nuxt 3 or 4:
```bash
npm install @emails.sh/sdk
```

.env:
```bash
# .env, gitignored by the Nuxt starter.
# The key comes from https://emails.sh/dashboard/api-keys.
NUXT_EMAILSSH_API_KEY=esh_your_key_here
```

nuxt.config.ts:
```ts
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server only. NUXT_EMAILSSH_API_KEY in the environment overrides this at
    // runtime, which is how it gets set in production.
    emailsshApiKey: ''
  }
});
```

### The server route

server/api/send.post.ts:
```ts
// server/api/send.post.ts
import { Emailssh } from '@emails.sh/sdk';

export default defineEventHandler(async (event) => {
  const { emailsshApiKey } = useRuntimeConfig(event);
  if (!emailsshApiKey) {
    throw createError({ statusCode: 500, statusMessage: 'NUXT_EMAILSSH_API_KEY is not set' });
  }

  const { to, subject, html } = await readBody(event);
  const mail = new Emailssh({ apiKey: emailsshApiKey });

  try {
    const { id } = await mail.send({ from: 'Acme <hello@acme.com>', to: [to], subject, html });
    return { id };
  } catch (err) {
    console.error('emails.sh refused the send', err);
    throw createError({ statusCode: 502, statusMessage: 'Could not send that email' });
  }
});
```

### Calling it from a page

components/SubscribeForm.vue script:
```ts
// In any component. The key never leaves the server; this is just a POST.
async function subscribe(address: string) {
  const { id } = await $fetch<{ id: string }>('/api/send', {
    method: 'POST',
    body: {
      to: address,
      subject: 'Confirm your subscription',
      html: '<p>Click the link in this email to confirm.</p>'
    }
  });
  return id;
}
```

## SvelteKit

https://emails.sh/docs/sveltekit

SvelteKit will not let you import a private environment variable into client code: the build fails rather than shipping the key. Use $env/static/private when the key is set at build time and $env/dynamic/private when it comes from the platform at runtime, which is the case on most adapters.

### Install

SvelteKit 2:
```bash
npm install @emails.sh/sdk
```

.env:
```bash
# .env, gitignored by the SvelteKit starter.
# The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
```

### A form action

src/routes/subscribe/+page.server.ts:
```ts
// src/routes/subscribe/+page.server.ts
import { fail } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { Emailssh } from '@emails.sh/sdk';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const address = String(data.get('email') ?? '');
    if (!address.includes('@')) return fail(400, { message: 'Enter an email address.' });

    const mail = new Emailssh({ apiKey: env.EMAILSSH_API_KEY });

    try {
      const { id } = await mail.send({
        from: 'Acme <hello@acme.com>',
        to: [address],
        subject: 'Confirm your subscription',
        html: '<p>Click the link in this email to confirm.</p>',
        idempotencyKey: `subscribe-${address}`
      });
      return { id };
    } catch (err) {
      console.error('emails.sh refused the send', err);
      return fail(502, { message: 'Could not send that email. Try again in a minute.' });
    }
  }
};
```

src/routes/subscribe/+page.svelte:
```text
<!-- src/routes/subscribe/+page.svelte -->
<script lang="ts">
  let { form } = $props();
</script>

<form method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <button type="submit">Subscribe</button>
</form>

{#if form?.message}<p>{form.message}</p>{/if}
{#if form?.id}<p>Check your inbox.</p>{/if}
```

### On Cloudflare

With adapter-cloudflare the variable arrives through platform.env rather than the process environment, and $env/dynamic/private reads it for you. Set it with npx wrangler secret put EMAILSSH_API_KEY, and see /docs/cloudflare-workers for the Worker case.

## Remix and React Router

https://emails.sh/docs/remix

In Remix, and in React Router 7 in framework mode, anything inside loader or action is stripped from the client bundle. The send goes there. This page works unchanged for both.

### Install

Remix 2 / React Router 7:
```bash
npm install @emails.sh/sdk
```

.env:
```bash
# .env, read by the dev server. The key comes from
# https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
```

### The route

app/routes/subscribe.tsx:
```ts
// app/routes/subscribe.tsx
import { Form, useActionData } from '@remix-run/react';
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { Emailssh } from '@emails.sh/sdk';

export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const address = String(form.get('email') ?? '');
  if (!address.includes('@')) return json({ message: 'Enter an email address.' }, { status: 400 });

  const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });

  try {
    const { id } = await mail.send({
      from: 'Acme <hello@acme.com>',
      to: [address],
      subject: 'Confirm your subscription',
      html: '<p>Click the link in this email to confirm.</p>',
      idempotencyKey: `subscribe-${address}`
    });
    return json({ id });
  } catch (err) {
    console.error('emails.sh refused the send', err);
    return json({ message: 'Could not send that email.' }, { status: 502 });
  }
}

export default function Subscribe() {
  const data = useActionData<typeof action>();

  return (
    <Form method="post">
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      <button type="submit">Subscribe</button>
      {data && 'message' in data ? <p>{data.message}</p> : null}
      {data && 'id' in data ? <p>Check your inbox.</p> : null}
    </Form>
  );
}
```

On the Cloudflare Pages adapter there is no process.env: the key arrives on context.cloudflare.env, so read it from the action arguments rather than the global.

## Astro

https://emails.sh/docs/astro

Astro is static by default, and a static build has no server to hold a key. Sending needs an adapter and a route that runs on the server.

### Install

Astro 4 or 5:
```bash
npm install @emails.sh/sdk
npx astro add node        # or: npx astro add vercel / cloudflare / netlify
```

.env:
```bash
# .env. The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
```

### The endpoint

src/pages/api/send.ts:
```ts
// src/pages/api/send.ts
import type { APIRoute } from 'astro';
import { Emailssh } from '@emails.sh/sdk';

// This route must run on the server, so opt it out of prerendering.
export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const { to, subject, html } = await request.json();
  const mail = new Emailssh({ apiKey: import.meta.env.EMAILSSH_API_KEY });

  try {
    const { id } = await mail.send({ from: 'Acme <hello@acme.com>', to: [to], subject, html });
    return new Response(JSON.stringify({ id }), {
      status: 200,
      headers: { 'content-type': 'application/json' }
    });
  } catch (err) {
    console.error('emails.sh refused the send', err);
    return new Response(JSON.stringify({ error: 'Could not send that email.' }), { status: 502 });
  }
};
```

import.meta.env.EMAILSSH_API_KEY without a PUBLIC_ prefix stays server side. A variable named PUBLIC_EMAILSSH_API_KEY would be inlined into the client bundle, which is why the name matters here.

## Express

https://emails.sh/docs/express

### Install

Node 18 or newer:
```bash
npm install express @emails.sh/sdk dotenv
```

.env:
```bash
# .env. The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
```

### The server

server.js:
```ts
// server.js
import 'dotenv/config';
import express from 'express';
import { Emailssh } from '@emails.sh/sdk';

if (!process.env.EMAILSSH_API_KEY) {
  throw new Error('EMAILSSH_API_KEY is not set. Add it to .env.');
}

// One client for the process. It holds no connection state, so a module-level
// instance is correct and cheaper than one per request.
const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });
const app = express();
app.use(express.json());

app.post('/signup', async (req, res) => {
  const { email } = req.body;
  if (typeof email !== 'string' || !email.includes('@')) {
    return res.status(400).json({ error: 'Enter an email address.' });
  }

  try {
    const { id } = await mail.send({
      from: 'Acme <hello@acme.com>',
      to: [email],
      subject: 'Welcome to Acme',
      html: '<p>Confirm your address to finish signing up.</p>',
      text: 'Confirm your address to finish signing up.',
      idempotencyKey: `signup-${email}`
    });
    res.json({ id });
  } catch (err) {
    // Log the reason, return something the user can act on.
    console.error('emails.sh refused the send', err);
    res.status(502).json({ error: 'Could not send the confirmation email.' });
  }
});

app.listen(3000, () => console.log('listening on http://localhost:3000'));
```

If the send is not something the caller waits on, do not make them: write the request to your database first, answer, and send after. A user should not see a 502 because an email provider had a slow second.

## Hono

https://emails.sh/docs/hono

Hono runs on several runtimes, and only one thing differs between them: where the key comes from. Read it from the context binding rather than from a global and the same file deploys everywhere.

### Install

Any Hono runtime:
```bash
npm install hono
```

### The route

src/index.ts:
```ts
// src/index.ts
import { Hono } from 'hono';
import { env } from 'hono/adapter';

type SendResult = { id: string; status: string };
type Refusal = { error: { code: string; message: string; next?: string } };

const app = new Hono();

app.post('/signup', async (c) => {
  // Works on Workers (bindings), Node, Bun, and Deno alike.
  const { EMAILSSH_API_KEY } = env<{ EMAILSSH_API_KEY: string }>(c);
  const { email } = await c.req.json<{ email: string }>();

  const response = await fetch('https://emails.sh/v1/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${EMAILSSH_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      from: 'Acme <hello@acme.com>',
      to: [email],
      subject: 'Welcome to Acme',
      html: '<p>Confirm your address to finish signing up.</p>',
      idempotencyKey: `signup-${email}`
    })
  });

  if (!response.ok) {
    const refusal = (await response.json()) as Refusal;
    console.error('emails.sh refused the send', refusal.error.code, refusal.error.next ?? refusal.error.message);
    return c.json({ error: 'Could not send the confirmation email.' }, 502);
  }

  const { id } = (await response.json()) as SendResult;
  return c.json({ id });
});

export default app;
```

On Node, set EMAILSSH_API_KEY in the process environment. On Workers, npx wrangler secret put EMAILSSH_API_KEY. The route above needs no change either way.

## Cloudflare Workers

https://emails.sh/docs/cloudflare-workers

A Worker has fetch and nothing else to install. The key belongs in a secret binding, not in wrangler.jsonc: vars are plain text in your repository and in the dashboard, secrets are not.

### Set the key

Once per environment:
```bash
npx wrangler secret put EMAILSSH_API_KEY
# paste the key from https://emails.sh/dashboard/api-keys when prompted

# For local development, put it in .dev.vars (gitignored):
echo 'EMAILSSH_API_KEY=esh_your_key_here' >> .dev.vars
```

### The Worker

src/index.ts:
```ts
// src/index.ts
export interface Env {
  EMAILSSH_API_KEY: string;
}

async function sendEmail(env: Env, to: string) {
  const response = await fetch('https://emails.sh/v1/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.EMAILSSH_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      from: 'Acme <hello@acme.com>',
      to: [to],
      subject: 'Welcome to Acme',
      html: '<p>Confirm your address to finish signing up.</p>',
      idempotency_key: `signup-${to}`
    })
  });

  if (!response.ok) {
    const { error } = (await response.json()) as { error: { code: string; message: string; next?: string } };
    throw new Error(`${error.code}: ${error.message} ${error.next ?? ''}`);
  }

  return (await response.json()) as { id: string };
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 });

    const { email } = (await request.json()) as { email: string };

    // Answer immediately and let the send finish after the response. waitUntil
    // keeps the Worker alive for it; a floating promise without it is killed.
    ctx.waitUntil(
      sendEmail(env, email).catch((err) => console.error('emails.sh refused the send', err))
    );

    return Response.json({ accepted: true });
  }
} satisfies ExportedHandler<Env>;
```

Use waitUntil only when the caller genuinely does not need the outcome. If the user is waiting to be told the email went, await the send and report the failure.

On a Cron Trigger or a Queue consumer, the same function works unchanged: it is one outbound fetch with a bearer token, and Workers place no restriction on that.

## Supabase

https://emails.sh/docs/supabase

Two different jobs live here. Sending your own transactional mail is an Edge Function. Replacing the confirmation and reset emails Supabase Auth sends is the Send Email Hook, and it is the reason most people arrive at this page: the built-in SMTP is rate limited and not meant for production.

### Set the key

Secrets for Edge Functions:
```bash
# The key comes from https://emails.sh/dashboard/api-keys.
npx supabase secrets set EMAILSSH_API_KEY=esh_your_key_here

# For local development, add it to supabase/.env (gitignored):
echo 'EMAILSSH_API_KEY=esh_your_key_here' >> supabase/.env
```

### An Edge Function

supabase/functions/send-email/index.ts:
```ts
// supabase/functions/send-email/index.ts
Deno.serve(async (request) => {
  const { to, subject, html } = await request.json();

  const response = await fetch('https://emails.sh/v1/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('EMAILSSH_API_KEY')}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ from: 'Acme <hello@acme.com>', to: [to], subject, html })
  });

  const body = await response.json();
  if (!response.ok) {
    console.error('emails.sh refused the send', body.error.code, body.error.next ?? body.error.message);
    return new Response(JSON.stringify({ error: 'Could not send that email.' }), { status: 502 });
  }

  return new Response(JSON.stringify({ id: body.id }), {
    headers: { 'content-type': 'application/json' }
  });
});
```

Deploy it:
```bash
npx supabase functions deploy send-email
```

### Auth emails through the Send Email Hook

Supabase calls a function of yours instead of sending the mail itself, passing the user and the token. You render the email and send it, which means the confirmation email finally looks like your product.

supabase/functions/auth-email/index.ts:
```ts
// supabase/functions/auth-email/index.ts
// Set the hook to this function's URL under Authentication, Hooks, Send Email.
Deno.serve(async (request) => {
  const { user, email_data } = await request.json();
  const link = `${email_data.site_url}/auth/confirm?token_hash=${email_data.token_hash}&type=${email_data.email_action_type}`;

  const subjects: Record<string, string> = {
    signup: 'Confirm your Acme account',
    recovery: 'Reset your Acme password',
    magiclink: 'Your Acme sign-in link',
    email_change: 'Confirm your new email address'
  };

  const response = await fetch('https://emails.sh/v1/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('EMAILSSH_API_KEY')}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      from: 'Acme <hello@acme.com>',
      to: [user.email],
      subject: subjects[email_data.email_action_type] ?? 'Acme',
      html: `<p>Click to continue: <a href="${link}">${link}</a></p><p>The link expires in an hour.</p>`,
      text: `Click to continue: ${link}\n\nThe link expires in an hour.`,
      tags: { template: email_data.email_action_type }
    })
  });

  if (!response.ok) {
    const refusal = await response.json();
    // Returning an error tells Supabase the mail did not go, so the user is
    // told rather than left waiting for something that never arrives.
    return new Response(JSON.stringify({ error: { message: refusal.error.message } }), { status: 500 });
  }

  return new Response('{}', { headers: { 'content-type': 'application/json' } });
});
```

Verify the hook signature before you trust the payload in production, and turn off "Enable custom SMTP" once the hook works, otherwise both paths try to send.

## Laravel

https://emails.sh/docs/laravel

You could call the API from a service class, but Laravel already has a mail layer with queues, Mailables, Blade templates, and Mail::fake() in tests. The right integration is a transport, and then nothing else in the application changes.

### The key

.env:
```bash
# .env, which Laravel gitignores. The key comes from
# https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
MAIL_MAILER=emailssh
MAIL_FROM_ADDRESS=hello@acme.com
MAIL_FROM_NAME=Acme
```

config/services.php:
```php
<?php
// config/services.php
return [
    // Leave the services already in this file where they are, and add:
    'emailssh' => [
        'key' => env('EMAILSSH_API_KEY'),
    ],
];
```

config/mail.php:
```php
<?php
// config/mail.php, in the 'mailers' array
'emailssh' => [
    'transport' => 'emailssh',
],
```

### The transport

app/Mail/EmailsshTransport.php:
```php
<?php
// app/Mail/EmailsshTransport.php
namespace App\Mail;

use Illuminate\Support\Facades\Http;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\MessageConverter;

class EmailsshTransport extends AbstractTransport
{
    public function __construct(private string $key)
    {
        parent::__construct();
    }

    protected function doSend(SentMessage $message): void
    {
        $email = MessageConverter::toEmail($message->getOriginalMessage());

        $payload = [
            'from' => $this->address($email->getFrom()[0]),
            'to' => array_map(fn ($a) => $a->getAddress(), $email->getTo()),
            'subject' => $email->getSubject(),
            'html' => $email->getHtmlBody(),
            'text' => $email->getTextBody(),
        ];

        if ($cc = $email->getCc()) {
            $payload['cc'] = array_map(fn ($a) => $a->getAddress(), $cc);
        }
        if ($replyTo = $email->getReplyTo()) {
            $payload['reply_to'] = $replyTo[0]->getAddress();
        }
        foreach ($email->getAttachments() as $attachment) {
            $payload['attachments'][] = [
                'filename' => $attachment->getFilename(),
                'content_type' => $attachment->getContentType(),
                'content_base64' => base64_encode($attachment->getBody()),
            ];
        }

        $response = Http::withToken($this->key)
            ->timeout(10)
            ->post('https://emails.sh/v1/emails', $payload);

        if ($response->failed()) {
            // The message says what to do about it, so keep it in the exception.
            throw new \RuntimeException(
                'emails.sh refused the send: ' . $response->json('error') . ': ' . $response->json('message')
            );
        }
    }

    private function address(\Symfony\Component\Mime\Address $address): string
    {
        return $address->getName()
            ? sprintf('%s <%s>', $address->getName(), $address->getAddress())
            : $address->getAddress();
    }

    public function __toString(): string
    {
        return 'emailssh';
    }
}
```

app/Providers/AppServiceProvider.php:
```php
<?php
// app/Providers/AppServiceProvider.php, in boot()
use App\Mail\EmailsshTransport;
use Illuminate\Support\Facades\Mail;

public function boot(): void
{
    Mail::extend('emailssh', function (array $config) {
        return new EmailsshTransport(config('services.emailssh.key'));
    });
}
```

### Use it

Anywhere in the application:
```php
<?php
use App\Mail\OrderShipped;
use Illuminate\Support\Facades\Mail;

// Everything Laravel already knows how to do now goes through emails.sh.
Mail::to($order->customer_email)->send(new OrderShipped($order));

// Or queue it, which is what you want on a web request.
Mail::to($order->customer_email)->queue(new OrderShipped($order));
```

php artisan config:clear after editing config/mail.php, otherwise a cached config keeps the old mailer and the change looks like it did nothing.

## Ruby on Rails

https://emails.sh/docs/rails

Register a delivery method and ActionMailer does the rest: your mailers, views, previews, and deliver_later all keep working, and the mail leaves through the API instead of SMTP.

### The key

config/credentials.yml.enc:
```bash
# Rails credentials are the idiomatic place. This opens an editor:
bin/rails credentials:edit

# Add:
#   emailssh_api_key: esh_your_key_here
# The key comes from https://emails.sh/dashboard/api-keys.
```

### The delivery method

lib/emailssh_delivery.rb:
```rb
# lib/emailssh_delivery.rb
require 'net/http'
require 'json'
require 'uri'

class EmailsshDelivery
  ENDPOINT = URI('https://emails.sh/v1/emails').freeze

  def initialize(settings)
    @key = settings.fetch(:api_key)
  end

  # ActionMailer hands us a Mail::Message. Pull out the parts the API wants.
  def deliver!(message)
    payload = {
      from: message[:from].formatted.first,
      to: Array(message.to),
      subject: message.subject,
      html: body_of(message, 'text/html'),
      text: body_of(message, 'text/plain')
    }.compact
    payload[:cc] = Array(message.cc) if message.cc
    payload[:reply_to] = Array(message.reply_to).first if message.reply_to

    request = Net::HTTP::Post.new(ENDPOINT)
    request['Authorization'] = "Bearer #{@key}"
    request['Content-Type'] = 'application/json'
    request.body = JSON.generate(payload)

    response = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, read_timeout: 10) do |http|
      http.request(request)
    end

    return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)

    refused = JSON.parse(response.body)['error']
    raise "emails.sh refused the send: #{refused['code']}: #{refused['message']} #{refused['next']}"
  end

  private

  def body_of(message, mime_type)
    return message.body.decoded if message.mime_type == mime_type

    message.find_first_mime_type(mime_type)&.decoded
  end
end
```

config/initializers/emailssh.rb:
```rb
# config/initializers/emailssh.rb
require Rails.root.join('lib/emailssh_delivery')

ActionMailer::Base.add_delivery_method(
  :emailssh,
  EmailsshDelivery,
  api_key: Rails.application.credentials.emailssh_api_key
)
```

config/environments/production.rb:
```rb
# config/environments/production.rb
config.action_mailer.delivery_method = :emailssh
config.action_mailer.default_options = { from: 'Acme <hello@acme.com>' }
config.action_mailer.default_url_options = { host: 'acme.com', protocol: 'https' }
```

### Use it

app/mailers/order_mailer.rb:
```rb
class OrderMailer < ApplicationMailer
  def shipped(order)
    @order = order
    mail(to: order.customer_email, subject: "Order #{order.number} has shipped")
  end
end

# In the controller or the job, unchanged from whatever you had before.
OrderMailer.shipped(order).deliver_later
```

Keep :test as the delivery method in the test environment. ActionMailer::Base.deliveries then works exactly as it always has, and no test sends real mail.

## Django

https://emails.sh/docs/django

Django routes all mail through EMAIL_BACKEND, including password resets and admin errors. Write one backend and everything in the project, including code you have not read, sends through emails.sh.

### The key

.env:
```bash
# .env, loaded with django-environ or python-dotenv.
# The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
```

### The backend

acme/email_backend.py:
```py
# acme/email_backend.py
import json
import urllib.error
import urllib.request

from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend

ENDPOINT = "https://emails.sh/v1/emails"


class EmailsshBackend(BaseEmailBackend):
    """Send Django's EmailMessage objects through the emails.sh API."""

    def send_messages(self, email_messages):
        sent = 0
        for message in email_messages:
            payload = {
                "from": message.from_email or settings.DEFAULT_FROM_EMAIL,
                "to": list(message.to),
                "subject": message.subject,
                "text": message.body,
            }
            if message.cc:
                payload["cc"] = list(message.cc)
            if message.bcc:
                payload["bcc"] = list(message.bcc)
            if message.reply_to:
                payload["reply_to"] = message.reply_to[0]

            # EmailMultiAlternatives puts the HTML part here.
            for content, mimetype in getattr(message, "alternatives", []):
                if mimetype == "text/html":
                    payload["html"] = content

            request = urllib.request.Request(
                ENDPOINT,
                data=json.dumps(payload).encode(),
                headers={
                    "Authorization": f"Bearer {settings.EMAILSSH_API_KEY}",
                    "Content-Type": "application/json",
                },
                method="POST",
            )

            try:
                with urllib.request.urlopen(request, timeout=10):
                    sent += 1
            except urllib.error.HTTPError as err:
                refusal = json.loads(err.read())["error"]
                if not self.fail_silently:
                    raise RuntimeError(
                        f"emails.sh refused the send: {refusal['code']}: "
                        f"{refusal['message']} {refusal.get('next', '')}"
                    ) from err

        return sent
```

settings.py:
```py
# settings.py
import os

EMAIL_BACKEND = "acme.email_backend.EmailsshBackend"
EMAILSSH_API_KEY = os.environ["EMAILSSH_API_KEY"]
DEFAULT_FROM_EMAIL = "Acme <hello@acme.com>"
```

### Use it

acme/emails.py:
```py
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string


def send_welcome(user):
    html = render_to_string("email/welcome.html", {"user": user})
    message = EmailMultiAlternatives(
        subject="Welcome to Acme",
        body="Confirm your address to finish signing up.",
        to=[user.email],
    )
    message.attach_alternative(html, "text/html")
    message.send()
```

Keep django.core.mail.backends.locmem.EmailBackend in the test settings. mail.outbox keeps working and no test sends anything.

## Flask

https://emails.sh/docs/flask

### Install

Python 3.9 or newer:
```bash
pip install flask emailssh python-dotenv
```

.env:
```bash
# .env. The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
```

### The app

app.py:
```py
# app.py
import os

from dotenv import load_dotenv
from emailssh import Emailssh, EmailsshError
from flask import Flask, jsonify, request

load_dotenv()

app = Flask(__name__)
mail = Emailssh(api_key=os.environ["EMAILSSH_API_KEY"])


@app.post("/signup")
def signup():
    address = (request.json or {}).get("email", "")
    if "@" not in address:
        return jsonify(error="Enter an email address."), 400

    try:
        sent = mail.send(
            from_="Acme <hello@acme.com>",
            to=[address],
            subject="Welcome to Acme",
            html="<p>Confirm your address to finish signing up.</p>",
            text="Confirm your address to finish signing up.",
            idempotency_key=f"signup-{address}",
        )
    except EmailsshError as err:
        # err says what to do about it; the user gets something they can act on.
        app.logger.error("emails.sh refused the send: %s", err)
        return jsonify(error="Could not send the confirmation email."), 502

    return jsonify(id=sent["id"])


if __name__ == "__main__":
    app.run(port=5000)
```

A send is a network call on the request path. Under any real traffic, hand it to Celery, RQ, or a thread and answer the user first.

## FastAPI

https://emails.sh/docs/fastapi

### Install

Python 3.9 or newer:
```bash
pip install fastapi uvicorn httpx
```

.env:
```bash
# .env, or set it in the environment of whatever runs uvicorn.
# The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
```

### The app

main.py:
```py
# main.py
import logging
import os
from contextlib import asynccontextmanager

import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel, EmailStr

log = logging.getLogger("uvicorn.error")


@asynccontextmanager
async def lifespan(app: FastAPI):
    # One client for the process: a new one per request leaks connections and
    # loses the pool, which shows up as latency long before it shows up as an error.
    app.state.http = httpx.AsyncClient(
        base_url="https://emails.sh",
        headers={"Authorization": f"Bearer {os.environ['EMAILSSH_API_KEY']}"},
        timeout=10.0,
    )
    yield
    await app.state.http.aclose()


app = FastAPI(lifespan=lifespan)


class Signup(BaseModel):
    email: EmailStr


async def send_welcome(client: httpx.AsyncClient, address: str) -> None:
    response = await client.post(
        "/v1/emails",
        json={
            "from": "Acme <hello@acme.com>",
            "to": [address],
            "subject": "Welcome to Acme",
            "html": "<p>Confirm your address to finish signing up.</p>",
            "idempotency_key": f"signup-{address}",
        },
    )
    if response.status_code >= 400:
        refusal = response.json()["error"]
        log.error("emails.sh refused the send: %s: %s", refusal["code"], refusal.get("next", refusal["message"]))


@app.post("/signup")
async def signup(body: Signup, background: BackgroundTasks):
    # Answer now, send after the response has gone out.
    background.add_task(send_welcome, app.state.http, body.email)
    return {"accepted": True}


@app.post("/signup-sync")
async def signup_sync(body: Signup):
    response = await app.state.http.post(
        "/v1/emails",
        json={
            "from": "Acme <hello@acme.com>",
            "to": [body.email],
            "subject": "Welcome to Acme",
            "html": "<p>Confirm your address to finish signing up.</p>",
        },
    )
    if response.status_code >= 400:
        refusal = response.json()["error"]
        log.error("emails.sh refused the send: %s: %s", refusal["code"], refusal["message"])
        raise HTTPException(status_code=502, detail="Could not send the confirmation email.")

    return {"id": response.json()["id"]}
```

Run it:
```bash
uvicorn main:app --reload
```

## Spring Boot

https://emails.sh/docs/spring

### The key

application.properties:
```text
# src/main/resources/application.properties
# The value comes from the EMAILSSH_API_KEY environment variable, so the key
# itself is never in the repository. Get one at
# https://emails.sh/dashboard/api-keys.
emailssh.api-key=${EMAILSSH_API_KEY}
emailssh.from=Acme <hello@acme.com>
```

### The client bean

EmailConfig.java:
```text
// src/main/java/com/acme/EmailConfig.java
package com.acme;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;

@Configuration
public class EmailConfig {

    @Bean
    public RestClient emailsshClient(@Value("${emailssh.api-key}") String apiKey) {
        return RestClient.builder()
                .baseUrl("https://emails.sh")
                .defaultHeader("Authorization", "Bearer " + apiKey)
                .build();
    }
}
```

### The service

EmailService.java:
```text
// src/main/java/com/acme/EmailService.java
package com.acme;

import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

@Service
public class EmailService {

    private final RestClient client;
    private final String from;

    public EmailService(RestClient emailsshClient, @Value("${emailssh.from}") String from) {
        this.client = emailsshClient;
        this.from = from;
    }

    public record Queued(String id, String status) {}

    @Async
    public void sendWelcome(String to) {
        try {
            Queued queued = client.post()
                    .uri("/v1/emails")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(Map.of(
                            "from", from,
                            "to", List.of(to),
                            "subject", "Welcome to Acme",
                            "html", "<p>Confirm your address to finish signing up.</p>",
                            "idempotency_key", "signup-" + to))
                    .retrieve()
                    .body(Queued.class);

            System.out.println("queued " + queued.id());
        } catch (RestClientResponseException e) {
            // The body carries {"error", "message"}; the message says what to do.
            System.err.println("emails.sh refused the send: " + e.getResponseBodyAsString());
        }
    }
}
```

@Async needs @EnableAsync on a configuration class and a task executor. Without it the annotation is ignored silently and the send blocks the request thread.

## Phoenix

https://emails.sh/docs/phoenix

Phoenix generates a Swoosh mailer for every new application. Point it at an adapter of yours and the emails your context modules already build go out through the API, with no other change.

### Configure

config/runtime.exs:
```text
# config/runtime.exs
# Read at boot, not compiled in. The key comes from
# https://emails.sh/dashboard/api-keys.
config :acme, Acme.Mailer,
  adapter: Acme.EmailsshAdapter,
  api_key: System.fetch_env!("EMAILSSH_API_KEY")
```

### The adapter

lib/acme/emailssh_adapter.ex:
```text
# lib/acme/emailssh_adapter.ex
defmodule Acme.EmailsshAdapter do
  @moduledoc "Swoosh adapter for emails.sh."
  use Swoosh.Adapter, required_config: [:api_key]

  @endpoint "https://emails.sh/v1/emails"

  @impl true
  def deliver(%Swoosh.Email{} = email, config) do
    body =
      %{
        from: address(email.from),
        to: Enum.map(email.to, &elem(&1, 1)),
        subject: email.subject,
        html: email.html_body,
        text: email.text_body
      }
      |> maybe_put(:cc, Enum.map(email.cc || [], &elem(&1, 1)))
      |> maybe_put(:bcc, Enum.map(email.bcc || [], &elem(&1, 1)))
      |> Map.reject(fn {_k, v} -> is_nil(v) end)

    case Req.post(@endpoint, json: body, auth: {:bearer, config[:api_key]}, receive_timeout: 10_000) do
      {:ok, %{status: status, body: %{"id" => id}}} when status < 400 ->
        {:ok, %{id: id}}

      {:ok, %{body: %{"error" => %{"code" => code, "message" => message}}}} ->
        {:error, {code, message}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp address({nil, addr}), do: addr
  defp address({name, addr}), do: "#{name} <#{addr}>"

  defp maybe_put(map, _key, []), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
end
```

### Use it

lib/acme/accounts/user_notifier.ex:
```text
# lib/acme/accounts/user_notifier.ex, as generated by phx.gen.auth
defmodule Acme.Accounts.UserNotifier do
  import Swoosh.Email
  alias Acme.Mailer

  def deliver_confirmation_instructions(user, url) do
    new()
    |> to({user.name, user.email})
    |> from({"Acme", "hello@acme.com"})
    |> subject("Confirm your Acme account")
    |> html_body("<p>Confirm your account: <a href=\"#{url}\">#{url}</a></p>")
    |> text_body("Confirm your account: #{url}")
    |> Mailer.deliver()
  end
end
```

Leave Swoosh.Adapters.Test configured in config/test.exs. assert_email_sent keeps working and the test suite sends nothing.

## Vercel

https://emails.sh/docs/vercel

A Vercel function is short lived and stateless, which is why every SMTP library fails there in a way that looks intermittent. A connection held open across invocations does not survive, and the send that worked in development times out in production. An HTTPS call has none of that: it is one request, it finishes, the function exits.

This page is the function itself. It works under any framework Vercel hosts, and as a bare function with no framework at all. The Next.js version of the same thing, with the App Router specifics, is at /docs/nextjs.

### Install

In the project root:
```bash
npm install @emails.sh/sdk
```

### The environment variables

Set them under Settings, then Environment Variables, and tick Production, Preview, and Development separately. A variable ticked only for Production is undefined in every preview deployment, which is the single most common way this works locally and not on a pull request.

Where the key goes:
```bash
# Settings, Environment Variables. Tick all three environments.
# The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"
CRON_SECRET=a_long_random_string

# Then pull them down for local development:
npx vercel env pull .env.local
```

Variables are injected into a deployment when it is built. Adding one to an existing deployment does nothing until you redeploy, and the symptom is a 401 unauthorized from a build that used to work.

### The function

api/send.ts:
```ts
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

export async function POST(request: Request): Promise<Response> {
  const body = (await request.json()) as { email?: string; name?: string };

  if (!body.email?.includes('@')) {
    return Response.json({ error: 'A valid email is required' }, { status: 400 });
  }

  const name = body.name ?? 'there';

  try {
    const sent = await mail.send({
      from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
      to: [body.email],
      subject: 'Welcome to Acme',
      html: `<p>Hi ${escapeHtml(name)}, your Acme account is ready.</p>`,
      text: `Hi ${name}, your Acme account is ready.`,
      // Derived from the address, so a double-submitted form is one email.
      idempotencyKey: `welcome:${body.email.toLowerCase()}`
    });

    return Response.json({ id: sent.id, status: sent.status });
  } catch (error) {
    console.error('emails.sh send failed', error);
    return Response.json({ error: 'Could not send right now' }, { status: 502 });
  }
}

function escapeHtml(value: string) {
  return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
```

The client is constructed once at module scope rather than per request, so a warm function reuses it. Nothing about it holds a socket open, so a cold start costs nothing either.

### The edge runtime

The same code runs unchanged on the edge runtime. The SDK is fetch on top of a JSON body and uses no Node built-in, so there is no polyfill and no bundler configuration. Reading an attachment off disk is the one thing that does not work there, because there is no disk: fetch the bytes or store them base64 encoded already.

api/edge-send.ts:
```ts
export const runtime = 'edge';

import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

export async function POST(): Promise<Response> {
  const sent = await mail.send({
    from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
    to: ['ada@example.com'],
    subject: 'Sent from the edge',
    text: 'No Node built-ins were involved.'
  });

  return Response.json({ id: sent.id });
}
```

### Cron

A Vercel cron job is an HTTP GET to a route of yours on a schedule. That route is a public URL, so without an authorisation check it is a button anybody on the internet can press to make you send mail. Vercel sends Authorization: Bearer with the CRON_SECRET you set, and comparing it is two lines.

api/cron/digest.ts:
```ts
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

export async function GET(request: Request): Promise<Response> {
  // Without this check the route is a public URL anyone can hit.
  if (request.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  const recipients = ['ada@example.com', 'grace@example.com'];

  // One request for up to 100 emails, so the function finishes well inside
  // its wall-clock budget and each recipient gets their own copy.
  await mail.batch(
    recipients.map((to) => ({
      from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
      to: [to],
      subject: 'Your Acme digest',
      html: '<p>Here is what happened yesterday.</p>',
      text: 'Here is what happened yesterday.'
    }))
  );

  return Response.json({ ok: true, count: recipients.length });
}
```

vercel.json:
```json
{
  "crons": [
    { "path": "/api/cron/digest", "schedule": "0 9 * * *" }
  ]
}
```

If the digest is going to a real list rather than a hardcoded array, a broadcast is a better fit than a cron job that loops: it checks topics and suppression per recipient, it reports per-recipient results, and it can be booked with scheduled_at instead of needing a cron at all. See /docs/broadcasts.

### What bites here

- **Nodemailer times out**: It expects a connection it can hold. A function that froze between invocations comes back to a socket the far end closed. Use the HTTPS call above, or /docs/smtp if the code genuinely cannot change.
- **It works in production and not in preview**: The variable was ticked for Production only. Tick Preview and Development too, then redeploy.
- **The function returns before the send finishes**: Await the send. A promise left floating in a serverless function is cancelled when the invocation ends, and the email silently never goes.
- **The cron route sends twice**: A retried invocation. Pass idempotency_key derived from the day and the recipient and the repeat sends nothing.
- **The key is in the client bundle**: Anything a component imports can end up in the browser. Keep the send in api/, and never prefix the variable with NEXT_PUBLIC_.

The full runnable version of this integration, including the form component, is at https://emails.sh/with/vercel.

## API reference

https://emails.sh/docs/rest

Base URL https://emails.sh/v1. Every request carries Authorization: Bearer esh_..., every body is JSON, and every response is JSON. There is no versioning header: /v1 is the version, and a breaking change would be /v2.

| Convention | What it means |
| --- | --- |
| Content-Type | application/json on anything with a body. A form encoding is rejected. |
| Timestamps | RFC 3339 with a Z offset, always UTC. |
| Ids | Prefixed and opaque: em_ for an email, dom_ for a domain, whd_ for a webhook delivery. Do not parse them. |
| Errors | Two shapes. Most routes answer { error: { code, message, next } }; topics, suppressions, templates, the contacts collection, and the rate limiter answer { error: "code", message?, hint? }. Parse both. See /docs/errors. |
| Rate limits | 600 requests a minute per key, which is 10 a second, and 30 a minute per IP with no key. Over it, 429 with retry-after in seconds. |
| Pagination | limit and before on list endpoints. Newest first unless the endpoint says otherwise. |

### Emails

- `POST /v1/emails` Send one email.
- `POST /v1/emails/batch` Send up to 100 in one request.
- `GET /v1/emails/:id` Status and delivery events for one email.
- `PATCH /v1/emails/:id` { send_at } moves a booked send to a new time. It keeps its id. 409 too_late_to_reschedule once it has gone.
- `GET /v1/emails` The delivery log. limit defaults to 25 and tops out at 100.
- `POST /v1/emails/:id/cancel` Call off an email booked with send_at, before it goes.
- `DELETE /v1/messages/scheduled/:id` The same cancel, by the older spelling. Still works.

#### `emails.send`

`{ from: string, to: string[], subject: string, html?: string, text?: string, cc?: string[], bcc?: string[], reply_to?: string | string[], headers?: Record<string, string>, attachments?: { filename, content_base64, content_type? }[], tags?: Record<string, string>, send_at?: string, idempotency_key?: string }`

Send one email. The endpoint every integration starts with, and the only one many ever use. 200 when the status is queued, 202 when it is scheduled.

| Parameter | Type | Required |
| --- | --- | --- |
| from | `string` | yes |
| to | `string[]` | yes |
| subject | `string` | yes |
| html | `string` | no |
| text | `string` | no |
| cc | `string[]` | no |
| bcc | `string[]` | no |
| reply_to | `string | string[]` | no |
| headers | `Record<string, string>` | no |
| attachments | `{ filename, content_base64, content_type? }[]` | no |
| tags | `Record<string, string>` | no |
| send_at | `string` | no |
| idempotency_key | `string` | no |

Returns: { id, status: "queued" | "scheduled" }

#### `emails.batch`

`{ emails: Send[] }`

Send up to 100 emails in one request. Each entry succeeds or fails on its own, and the response keeps the order you sent them in.

| Parameter | Type | Required |
| --- | --- | --- |
| emails | `Send[]` | yes |

Returns: { data: ({ id, status } | { id: null, status: "failed", error })[] }

#### `emails.get`

`{ id: string }`

Status and delivery events for one email: when it was accepted, when the receiving server took it, and the bounce or complaint if there was one.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { id, status, to, subject, created_at, events[] }

### Domains

- `GET /v1/domains` Domains, with the records a pending one still needs.
- `POST /v1/domains` { domain } returns every DNS record to publish.
- `GET /v1/domains/:id` One domain, with the records it still needs if it is pending.
- `POST /v1/domains/:id/verify` Check the records now and report which are missing.
- `PATCH /v1/domains/:id` { tracking_host } sets the hostname in front of tracked links. null goes back to the shared one.
- `DELETE /v1/domains/:id` Remove a domain. DELETE /v1/domains?id= is the older spelling and still works.

#### `domains.list`

`{ }`

Every sending domain on the workspace, with the DNS records a pending one still needs.

Returns: { domains: Domain[] }

#### `domains.create`

`{ domain: string }`

Add a sending domain. The response carries every DNS record to publish, so setup can finish without opening the dashboard.

| Parameter | Type | Required |
| --- | --- | --- |
| domain | `string` | yes |

Returns: { id, domain, verification_status, records[] }

#### `domains.get`

`{ id: string }`

One domain, in the shape the list gives it, with the DNS records still to publish if it is pending. An id from another workspace reads as missing.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Domain

#### `domains.verify`

`{ id: string }`

Check the records now rather than waiting for the nightly pass. Safe to call repeatedly, and the answer says which records are still missing.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { domain, verified, records: (Record & { found })[] }

#### `domains.delete`

`{ id: string }`

Remove a domain. Anything still sending from it starts failing, so move senders first.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { deleted: id }

### API keys

- `GET /v1/api-keys` Keys on the workspace. Values are never listed.
- `POST /v1/api-keys` { name, scopes? } returns the key once.
- `DELETE /v1/api-keys/:id` Revoke a key, effective on the next request.

#### `api_keys.list`

`{ }`

Keys on the workspace, with the last time each was used. Values are never listed.

Returns: { api_keys: ApiKey[] }

#### `api_keys.create`

`{ name: string, scopes?: string[] }`

Create a key. The value is returned once and never again.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |
| scopes | `string[]` | no |

Returns: { id, name, key }

#### `api_keys.revoke`

`{ id: string }`

Revoke a key. It stops working on the next request, with no grace period.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { deleted: id }

### Webhooks

- `GET /v1/webhooks` Endpoints. Secrets are not listed.
- `POST /v1/webhooks` { url, events?, headers? } returns the signing secret once.
- `GET /v1/webhooks/:id` One endpoint. The secret is never in a read.
- `PATCH /v1/webhooks/:id` Change an endpoint. rotate_secret returns a new secret once. PATCH /v1/webhooks?id= is the older spelling and still works.
- `DELETE /v1/webhooks/:id` Remove an endpoint. DELETE /v1/webhooks?id= is the older spelling and still works.
- `GET /v1/webhooks/deliveries` What each attempt got back. webhook_id and limit narrow it.
- `POST /v1/webhooks/deliveries` { webhook_id } sends a test; { delivery_id } replays a stored one.

#### `webhooks.list`

`{ }`

Registered endpoints and the events each is subscribed to. Signing secrets are not listed.

Returns: { webhooks: Webhook[] }

#### `webhooks.create`

`{ url: string, events?: string[], headers?: Record<string, string> }`

Register an endpoint. The signing secret comes back once, in this response.

| Parameter | Type | Required |
| --- | --- | --- |
| url | `string` | yes |
| events | `string[]` | no |
| headers | `Record<string, string>` | no |

Returns: { id, url, events, secret }

#### `webhooks.get`

`{ id: string }`

One endpoint, with its events, whether it is active, and its failure counters. The signing secret is not in a read, at any scope.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Webhook

#### `webhooks.update`

`{ id: string, url?: string, events?: string[], active?: boolean, headers?: Record<string, string>, rotate_secret?: boolean }`

Change an endpoint in place. Fields you leave out are left alone.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| url | `string` | no |
| events | `string[]` | no |
| active | `boolean` | no |
| headers | `Record<string, string>` | no |
| rotate_secret | `boolean` | no |

Returns: Webhook, plus secret and previous_secret_valid_until when rotate_secret was set

#### `webhooks.deliveries`

`{ webhook_id?: string, limit?: number }`

What each attempt got back: status code, response body, and duration. This is how you tell a broken endpoint from a missing event.

| Parameter | Type | Required |
| --- | --- | --- |
| webhook_id | `string` | no |
| limit | `number` | no |

Returns: { deliveries: Delivery[] }

#### `webhooks.delete`

`{ id: string }`

Remove an endpoint. Queued deliveries for it are dropped.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { deleted: id }

### Audiences

- `GET /v1/audiences` Audiences on the workspace.
- `POST /v1/audiences` { name, description? } creates one.
- `GET /v1/audiences/:id` One audience, with its double opt-in setting and where that setting came from.
- `PATCH /v1/audiences/:id` { name?, description?, require_double_opt_in?, confirmation_subject?, confirmation_body? }
- `DELETE /v1/audiences/:id` Soft delete an audience.
- `GET /v1/audiences/:id/contacts` Members. ?status=&limit=&offset=. id on each row is the membership id.
- `POST /v1/audiences/:id/contacts` Bulk import of { email, attributes?, status?, tags? } rows, as JSON or CSV. ?dry_run=true reports without writing.
- `GET /v1/audiences/:id/contacts/:member` One membership, by membership id.
- `PATCH /v1/audiences/:id/contacts/:member` { attributes?, subscribed?, status? } on one membership.
- `DELETE /v1/audiences/:id/contacts/:member` Remove one membership. It records no unsubscribe.
- `POST /v1/audiences/:id/contacts/:member/confirm` Send the double opt-in confirmation email. Nothing else ever sends it.

#### `audiences.list`

`{ }`

Audiences on the workspace. An audience is a named list of contacts with their subscription state.

Returns: { audiences: Audience[] }

#### `audiences.create`

`{ name: string }`

Create an audience.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |

Returns: { id, name }

#### `audiences.members`

`{ audience_id: string, status?: string, limit?: number, offset?: number }`

Members of one audience. Each row carries a membership id, which is the id every other member route takes, and a contact_id, which is the workspace-level contact behind it.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| status | `string` | no |
| limit | `number` | no |
| offset | `number` | no |

Returns: { contact_count, subscribed_count, contacts: Member[] }

#### `audiences.import`

`{ audience_id: string, contacts: { email, attributes?, status?, tags? }[], dry_run?: boolean }`

Bulk import into an audience. Up to 10000 rows and 8 MB per call. An address already on the suppression list lands as cleaned rather than subscribed and is counted in held_back.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| contacts | `{ email, attributes?, status?, tags? }[]` | yes |
| dry_run | `boolean` | no |

Returns: { created, updated, unchanged, duplicates, tags_added, imported, held_back, skipped, held[], errors[] }

#### `audiences.updateMember`

`{ audience_id: string, member: string, attributes?: Record<string, string>, subscribed?: boolean, status?: string }`

Change one membership. Recording an unsubscribe here is what keeps the address from being mailed by the next broadcast.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| member | `string` | yes |
| attributes | `Record<string, string>` | no |
| subscribed | `boolean` | no |
| status | `string` | no |

Returns: Member

#### `audiences.removeMember`

`{ audience_id: string, member: string }`

Remove one membership from an audience. It does not delete the contact, and it does not record an unsubscribe: set status to unsubscribed for that.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| member | `string` | yes |

Returns: { deleted: id }

### Broadcasts

- `GET /v1/broadcasts` Broadcasts, newest first. limit defaults to 50 and tops out at 100.
- `POST /v1/broadcasts` Create a draft. from is the one required field.
- `GET /v1/broadcasts/:id` One broadcast with its body, its stats, and its problems[].
- `PATCH /v1/broadcasts/:id` Edit a draft. from is not patchable, and a broadcast past draft answers 409.
- `DELETE /v1/broadcasts/:id` Cancel it.
- `POST /v1/broadcasts/:id/cancel` The same cancel, as a POST.
- `POST /v1/broadcasts/:id/send` { scheduled_at? }. Without it, it goes now.
- `POST /v1/broadcasts/:id/test` { to } sends it to up to 5 addresses of yours.
- `GET /v1/broadcasts/:id/preview` ?email= renders it without sending, and lists the merge fields.
- `POST /v1/broadcasts/preview` The same render for a body you have not saved. Nothing is written.
- `GET /v1/broadcasts/:id/recipients` ?status=&limit=&offset= over per-recipient results.

#### `broadcasts.create`

`{ from: string, audience_id?: string, segment_id?: string, topic_id?: string, subject?: string, name?: string, html?: string, text?: string, template_id?: string, template_version_id?: string, reply_to?: string, track_opens?: boolean, track_clicks?: boolean }`

Create a broadcast as a draft. The response carries ready and problems[], so you can tell whether it can send yet without trying.

| Parameter | Type | Required |
| --- | --- | --- |
| from | `string` | yes |
| audience_id | `string` | no |
| segment_id | `string` | no |
| topic_id | `string` | no |
| subject | `string` | no |
| name | `string` | no |
| html | `string` | no |
| text | `string` | no |
| template_id | `string` | no |
| template_version_id | `string` | no |
| reply_to | `string` | no |
| track_opens | `boolean` | no |
| track_clicks | `boolean` | no |

Returns: { id, status: "draft", ready, problems[] }

#### `broadcasts.send`

`{ id: string, scheduled_at?: string }`

Send a draft, or book it. Sending now answers with the recipient count and how many were skipped; booking answers with the time it will go.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| scheduled_at | `string` | no |

Returns: { id, status, queued, batches, skipped, recipients } or { id, status, scheduled_at }

#### `broadcasts.test`

`{ id: string, to: string | string[] }`

Send the broadcast to yourself first, rendered exactly as a recipient would get it. It does not change the draft status.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| to | `string | string[]` | yes |

Returns: { sent, skipped }

#### `broadcasts.preview`

`{ id: string, email?: string }`

The rendered subject and body without sending anything, plus merge_fields saying which fields the audience actually supplies and which are missing.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| email | `string` | no |

Returns: { subject, html, text, merge_fields[], audience_sample_size }

#### `broadcasts.previewContent`

`{ subject: string, html?: string, text?: string, from?: string, replyTo?: string, audienceId?: string, email?: string }`

The same render as broadcasts.preview, for a body you have not saved. Nothing is written and nothing is sent, so id comes back null.

| Parameter | Type | Required |
| --- | --- | --- |
| subject | `string` | yes |
| html | `string` | no |
| text | `string` | no |
| from | `string` | no |
| replyTo | `string` | no |
| audienceId | `string` | no |
| email | `string` | no |

Returns: { id: null, subject, html, text, headers, merge_fields[], audience_sample_size }

#### `broadcasts.recipients`

`{ id: string, status?: string, limit?: number, offset?: number }`

Per-recipient results for one broadcast, with the reason a skipped or failed row did not go.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| status | `string` | no |
| limit | `number` | no |
| offset | `number` | no |

Returns: { stats, recipients: Recipient[] }

#### `broadcasts.cancel`

`{ id: string }`

Call off a scheduled or sending broadcast. Messages already handed to the mail servers have gone.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { id, status: "cancelled" }

### Segments

- `GET /v1/segments` Segments. ?audience_id= narrows to one audience.
- `POST /v1/segments` { name, audience_id?, match?, rules? } creates one.
- `GET /v1/segments/:id` ?count=live recomputes member_count instead of reading the cached one.
- `PATCH /v1/segments/:id` Rules are replaced wholesale, never merged.
- `DELETE /v1/segments/:id` Remove a segment. Contacts are untouched.
- `GET /v1/segments/:id/members` ?mailable=true&limit=&after= over who it matches now.

#### `segments.create`

`{ name: string, audience_id?: string, match?: "all" | "any", rules?: Rule[], description?: string }`

A saved filter over contacts. Membership is computed when it is read rather than stored, so a segment is never stale.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |
| audience_id | `string` | no |
| match | `"all" | "any"` | no |
| rules | `Rule[]` | no |
| description | `string` | no |

Returns: { id, name, describes, member_count }

#### `segments.get`

`{ id: string, count?: string }`

One segment, its rules, and the sentence describing them.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| count | `string` | no |

Returns: Segment

#### `segments.update`

`{ id: string, match?: "all" | "any", rules?: Rule[] }`

Change a segment. Send the whole rule list every time, including the rules you are keeping.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| match | `"all" | "any"` | no |
| rules | `Rule[]` | no |

Returns: Segment

#### `segments.members`

`{ id: string, mailable?: boolean, limit?: number, after?: string }`

Who a segment currently matches, as a cursor-paged list.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| mailable | `boolean` | no |
| limit | `number` | no |
| after | `string` | no |

Returns: { segment_id, total, next_after, members[] }

### Contacts

- `GET /v1/contacts` ?q=&lookup=&limit=. Accept: text/vcard returns a .vcf instead of JSON.
- `POST /v1/contacts` Field mode, or { vcard } for up to 1000 cards at once.
- `GET /v1/contacts/:id` One contact.
- `PATCH /v1/contacts/:id` Change a contact.
- `DELETE /v1/contacts/:id` Remove a contact.
- `GET /v1/contacts/duplicates` Likely duplicate pairs. POST merges { survivor_id, loser_id }.
- `GET /v1/contacts/:id/tags` Tags on a contact.
- `POST /v1/contacts/:id/tags` { tags } or { tag }. Each real change queues a tag.added automation event.
- `DELETE /v1/contacts/:id/tags` ?tag=vip removes one, and queues tag.removed.
- `GET /v1/contacts/:id/attributes` Workspace-level attributes on a contact.
- `PATCH /v1/contacts/:id/attributes` Merge patch. null clears one name.
- `GET /v1/contacts/:id/subscriptions` Every address, whether it is suppressed, and what it is subscribed to.

#### `contacts.tags`

`{ id: string, tags?: string[] }`

Read or add tags on a contact. Tags are 1 to 64 characters, hold no comma or newline, are compared without case, and are stored lowercased.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| tags | `string[]` | no |

Returns: { tags, added }

#### `contacts.attributes`

`{ id: string, attributes?: Record<string, string | null> }`

Workspace-level facts about a contact, readable by every segment and automation. These are not the per-list merge fields a broadcast substitutes.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| attributes | `Record<string, string | null>` | no |

Returns: { attributes, changed[] }

#### `contacts.subscriptions`

`{ id: string }`

Every address on a contact, whether it is suppressed, and the audiences and topics it is subscribed to. The one call that answers "will this person receive anything".

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { contact_id, subscriptions[] }

### Templates

- `GET /v1/templates` Templates on the workspace.
- `POST /v1/templates` { name, slug?, subject?, html?, text?, variables?, publish? }
- `GET /v1/templates/:id` One template with every version.
- `PATCH /v1/templates/:id` { name?, slug?, description? } only. Content is never edited here.
- `DELETE /v1/templates/:id` Soft delete.
- `GET /v1/templates/:id/versions` Versions and which one is published.
- `POST /v1/templates/:id/versions` Write a draft version. It never publishes.
- `POST /v1/templates/:id/publish` { version_id } or { version }, or neither to publish the latest.
- `POST /v1/templates/:id/render` Strict render of the published version, exactly as a send does it.
- `POST /v1/templates/:id/preview` Lenient render of any version, with markup warnings.

#### `templates.create`

`{ name: string, slug?: string, subject?: string, html?: string, text?: string, variables?: (string | { name, default?, required? })[], publish?: boolean }`

Create a template and its first version.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |
| slug | `string` | no |
| subject | `string` | no |
| html | `string` | no |
| text | `string` | no |
| variables | `(string | { name, default?, required? })[]` | no |
| publish | `boolean` | no |

Returns: { id, name, slug, published_version_id, latest_version, sendable }

#### `templates.addVersion`

`{ id: string, subject?: string, html?: string, text?: string, variables?: (string | { name, default?, required? })[] }`

Write a new draft version. It never publishes, so editing a password reset reaches nobody until you say so.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| subject | `string` | no |
| html | `string` | no |
| text | `string` | no |
| variables | `(string | { name, default?, required? })[]` | no |

Returns: Version

#### `templates.publish`

`{ id: string, version_id?: string }`

Point live sends at a version.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| version_id | `string` | no |

Returns: { published_version_id }

#### `templates.render`

`{ id: string, variables?: Record<string, string>, preheader?: string }`

Render the published version strictly, exactly as a send would. A missing variable is 422 template_variables_missing rather than a blank.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| variables | `Record<string, string>` | no |
| preheader | `string` | no |

Returns: { subject, html, text, preheader }

#### `templates.preview`

`{ id: string, version_id?: string, variables?: Record<string, string>, preheader?: string }`

Render leniently, filling anything you did not supply with a sample. Also returns warnings about markup mail clients will not render.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| version_id | `string` | no |
| variables | `Record<string, string>` | no |
| preheader | `string` | no |

Returns: { subject, html, text, values, filled_with_samples, warnings[] }

### Topics

- `GET /v1/topics` ?include_archived=true to see the retired ones as well.
- `POST /v1/topics` { name, key?, description?, default_opt_in?, required? }
- `GET /v1/topics/:id` One topic.
- `PATCH /v1/topics/:id` Everything but key, which is immutable.
- `DELETE /v1/topics/:id` Archives rather than deletes.
- `GET /v1/topics/preferences` ?email= returns what one address has said, and its preference page URL.
- `POST /v1/topics/preferences` { email, topic, subscribed?, source? }. Omit subscribed to ask rather than write.

#### `topics.create`

`{ name: string, key?: string, description?: string, default_opt_in?: boolean, required?: boolean }`

A named category of mail a recipient can turn off on its own.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |
| key | `string` | no |
| description | `string` | no |
| default_opt_in | `boolean` | no |
| required | `boolean` | no |

Returns: Topic

#### `topics.preferences`

`{ email: string, topic?: string, subscribed?: boolean, source?: string }`

Read or record what one address has said about your topics. Keyed by address, so somebody who was never in an audience still has a working opt-out.

| Parameter | Type | Required |
| --- | --- | --- |
| email | `string` | yes |
| topic | `string` | no |
| subscribed | `boolean` | no |
| source | `string` | no |

Returns: { email, preference_url, preferences[] }

### Automations

- `GET /v1/automations` Automations, with their trigger, version, and last error.
- `POST /v1/automations` Raw YAML, or { yaml } as JSON.
- `GET /v1/automations/:id` One automation, with its graph, its YAML, and its trigger URL if it has one.
- `PATCH /v1/automations/:id` { enabled?, yaml? }
- `DELETE /v1/automations/:id` Remove it, and cancel every waiting run.
- `GET /v1/automations/:id.yaml` The document, with the version in x-emailssh-automation-version.
- `PUT /v1/automations/:id.yaml` Replace the document with the raw YAML body.
- `GET /v1/automations/:id/versions` The last 50 versions, each with its YAML.
- `POST /v1/automations/:id/versions` { version_id } restores one as a new version.
- `POST /v1/automations/:id/trigger` { email | contact_id, idempotency_key, data? } starts a run.
- `GET /v1/automations/:id/runs` ?status=&limit= over runs.
- `GET /v1/automations/:id/runs/:runId` One run and every step it executed.
- `DELETE /v1/automations/:id/runs/:runId` Cancel a run that is waiting.

#### `automations.create`

`{ yaml: string }`

Create an automation from a YAML document. It is validated whole: a refusal names the field, says what to write instead, and carries the line number.

| Parameter | Type | Required |
| --- | --- | --- |
| yaml | `string` | yes |

Returns: { id, name, slug, trigger, enabled, version, yaml }

#### `automations.pull`

`{ id: string }`

GET /v1/automations/:id.yaml. The raw document, with the current version in the x-emailssh-automation-version response header. Comments and key order survive the round trip.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: text/yaml

#### `automations.push`

`{ id: string, yaml: string }`

PUT /v1/automations/:id.yaml. Replaces the document and writes a new version.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| yaml | `string` | yes |

Returns: text/yaml

#### `automations.trigger`

`{ id: string, email?: string, contact_id?: string, idempotency_key: string, data?: object }`

Start a run of an automation whose trigger is api.call.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| email | `string` | no |
| contact_id | `string` | no |
| idempotency_key | `string` | yes |
| data | `object` | no |

Returns: { run_id, status }

#### `automations.runs`

`{ id: string, status?: string, limit?: number }`

Runs of one automation, with how many steps executed, how many emails went, and the error if it stopped.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| status | `string` | no |
| limit | `number` | no |

Returns: { runs: Run[] }

### Analytics

- `GET /v1/analytics` ?from=&to=&group_by=&breakdown=&mail_class=&domain=&template_id=&tag=
- `GET /v1/analytics/tags` ?days= returns the tag keys worth breaking down by.

#### `analytics.get`

`{ from?: string, to?: string, group_by?: "day" | "week" | "month", breakdown?: "domain" | "tag" | "mail_class" | "template", tag_key?: string, mail_class?: "transactional" | "marketing", domain?: string, template_id?: string, tag?: string }`

Sends, deliveries, bounces, complaints, clicks, and opens over a window, as totals and as a series.

| Parameter | Type | Required |
| --- | --- | --- |
| from | `string` | no |
| to | `string` | no |
| group_by | `"day" | "week" | "month"` | no |
| breakdown | `"domain" | "tag" | "mail_class" | "template"` | no |
| tag_key | `string` | no |
| mail_class | `"transactional" | "marketing"` | no |
| domain | `string` | no |
| template_id | `string` | no |
| tag | `string` | no |

Returns: { range, totals, series[], breakdown, notes }

#### `analytics.tags`

`{ days?: number }`

Which tag keys are worth breaking down by, with how much mail each carried. Up to 25.

| Parameter | Type | Required |
| --- | --- | --- |
| days | `number` | no |

Returns: { tag_keys: [{ key, sent }] }

### Suppressions

- `GET /v1/suppressions` ?reason=&email=&limit=&before= over blocked addresses.
- `POST /v1/suppressions` { email, reason? } blocks one yourself.
- `DELETE /v1/suppressions/:id` Lift one by id. A global row answers 404, and so does another workspace's.
- `DELETE /v1/suppressions` ?email= clears one by address. ?id= is the older spelling of the route above and still works.

#### `suppressions.list`

`{ reason?: "bounce" | "complaint" | "unsub" | "manual", email?: string, limit?: number, before?: string }`

Addresses nothing will reach on this workspace, and why each one is there. Rows with is_global set are ours rather than yours.

| Parameter | Type | Required |
| --- | --- | --- |
| reason | `"bounce" | "complaint" | "unsub" | "manual"` | no |
| email | `string` | no |
| limit | `number` | no |
| before | `string` | no |

Returns: { suppressions[], next_cursor, total }

#### `suppressions.create`

`{ email: string, reason?: string }`

Block an address yourself, for somebody who asked you to stop by replying rather than by clicking.

| Parameter | Type | Required |
| --- | --- | --- |
| email | `string` | yes |
| reason | `string` | no |

Returns: Suppression

#### `suppressions.delete`

`{ id?: string, email?: string }`

Clear one, when you know the address is good again. A global row answers 404 and cannot be cleared.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | no |
| email | `string` | no |

Returns: { deleted }

### Dedicated IPs

- `GET /v1/ips` Dedicated addresses, shared pools, the default region, and data residency.
- `GET /v1/ips/:id` One address, with a live reverse DNS check.
- `PATCH /v1/ips/:id` { paused?, daily_cap? } and nothing else.

#### `ips.list`

`{ }`

Dedicated addresses on the workspace, the shared pools anything else goes through, the default region, and where sending, storage, and compute physically happen.

Returns: { default_region, residency, ips[], shared_pools[] }

#### `ips.get`

`{ id: string }`

One address, plus a live reverse DNS check: what the PTR should say, what it says, and whether the forward lookup confirms it.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Ip & { reverse_dns }

#### `ips.update`

`{ id: string, paused?: boolean, daily_cap?: number | null }`

The only two things about an address you can change. POST /v1/ips takes one out of inventory and DELETE /v1/ips/:id hands it back; this route is for pausing one and lowering its cap.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| paused | `boolean` | no |
| daily_cap | `number | null` | no |

Returns: Ip

### Received mail

- `GET /v1/messages` ?unread_only=true&thread_id=&limit= over received mail.
- `GET /v1/messages/:id` One received message in full.
- `POST /v1/messages/:id/reply-all` { body } answers on the same thread.
- `POST /v1/messages/:id/forward` { to, body?, mode? }
- `POST /v1/messages/:id/archive` { archived?, unread? }
- `GET /v1/messages/:id/attachments/:filename` One attachment, as its own bytes.
- `GET /v1/threads` Conversations, newest first.
- `GET /v1/threads/:id` Every message in one conversation.
- `GET /v1/search` ?q= ranked full-text search over received mail.

#### `messages.list`

`{ unread_only?: boolean, thread_id?: string, limit?: number }`

Mail that arrived at an address on a domain of yours with inbound turned on.

| Parameter | Type | Required |
| --- | --- | --- |
| unread_only | `boolean` | no |
| thread_id | `string` | no |
| limit | `number` | no |

Returns: { messages: Message[] }

#### `messages.get`

`{ id: string }`

One received message with its full body, headers, and attachment list.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Message

#### `messages.reply`

`{ id: string, html?: string, text?: string }`

Answer a received message on its own thread, with References and In-Reply-To set for you.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| html | `string` | no |
| text | `string` | no |

Returns: { id, status }

#### `threads.get`

`{ id: string }`

Every message in one conversation, oldest first.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Thread

The full machine-readable description is at https://emails.sh/openapi.json, in OpenAPI 3.1. Generate a client from it if your language has a generator you trust.

## Errors

https://emails.sh/docs/errors

A refusal is JSON with a code in it, and prose saying what to do about it. The prose is there because the thing reading the error is often a coding assistant halfway through wiring up a signup flow, and "the domain is not verified" is only actionable next to "verify it here, or send from onboarding@emails.sh while testing".

### There are two body shapes

Most of the API answers with a nested object: code, which is stable and safe to switch on; message, which says what happened; and next, which says what to do about it. This is the shape you get from /v1/emails, /v1/domains, /v1/webhooks, /v1/api-keys, /v1/audiences, /v1/broadcasts, /v1/segments, /v1/automations, /v1/analytics, /v1/ips, and the tag, attribute, and subscription routes under /v1/contacts/:id.

The nested shape:
```json
{
  "error": {
    "code": "invalid_from_domain",
    "message": "the domain acme.com is not verified on this workspace",
    "next": "Verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while you are testing."
  }
}
```

A handful of routes still answer with a flat body instead: error is the code itself as a string, and message and hint are optional siblings rather than nested. Those routes are /v1/topics, /v1/suppressions, /v1/templates, the /v1/contacts collection and item routes including /v1/contacts/duplicates, and the rate limiter, which means a 429 rate_limited is flat whichever endpoint produced it.

The flat shape:
```json
{
  "error": "template_not_published",
  "message": "Template welcome has no published version, so there is nothing to send.",
  "hint": "Publish one with POST /v1/templates/{id}/publish."
}
```

Parse tolerantly. Read error, and if it is an object take error.code, and if it is a string take it as the code. Five lines once in your client is cheaper than discovering the difference in production, and the codes themselves are the same vocabulary either way.

Read either one:
```ts
interface Refusal {
  code: string;
  message: string;
  next?: string;
}

// Both shapes, one function. error is either { code, message, next } or the
// code as a bare string with message and hint alongside it.
export function readRefusal(body: unknown, status: number): Refusal {
  const b = (body ?? {}) as Record<string, unknown>;
  const e = b.error;

  if (e && typeof e === 'object') {
    const nested = e as Record<string, unknown>;
    return {
      code: String(nested.code ?? 'internal_error'),
      message: String(nested.message ?? `HTTP ${status}`),
      next: typeof nested.next === 'string' ? nested.next : undefined
    };
  }

  return {
    code: typeof e === 'string' ? e : 'internal_error',
    message: typeof b.message === 'string' ? b.message : `HTTP ${status}`,
    next: typeof b.hint === 'string' ? b.hint : undefined
  };
}
```

Switch on the code. Do not match on message, next, or hint: the prose is deliberately improved over time, and code that greps it breaks when we make it clearer. Both SDKs do the normalising above for you and put the result on the error they throw, as code, message, and nextStep (next_step in Python).

### The catalogue

| Code | Status | What to do |
| --- | --- | --- |
| unauthorized | 401 | No key, or a key that is wrong, revoked, or from another workspace. Keys start with esh_ and are made at https://emails.sh/dashboard/api-keys. |
| insufficient_scope | 403 | The key is real but was not given this permission. Use a full-access key, or make one with the scope. |
| sending_locked | 403 | The workspace owner has never confirmed their email address. Nothing sends until they do, and no card is involved. |
| workspace_paused | 403 | Sending is paused because the bounce or complaint rate crossed a threshold. Not retryable. See https://emails.sh/dashboard/activity. |
| invalid_json | 400 | The body did not parse. Check the Content-Type header and the quoting. |
| invalid_request | 400 | The body parsed but was not an object, or a field was the wrong shape. The message names it. |
| missing_from | 400 | No from address. |
| missing_to | 400 | No recipients. |
| missing_subject | 400 | No subject. |
| missing_body | 400 | Neither html nor text, and no template. |
| invalid_from | 400 | The from address did not parse as an address or as "Name <address>". |
| invalid_recipient | 400 | One of the recipients is not a valid address. The message names it. |
| too_many_recipients | 400 | More than 50 addresses across to, cc, AND bcc. Send one email per recipient through POST /v1/emails/batch. |
| mixed_test_and_real_recipients | 400 | The recipients mix a reserved test address such as delivered@emails.sh with a real one. Nothing was sent to anybody. A send is either a test or it is real. |
| invalid_template | 400 | template was given but is not { id, variables? }, or names a template that does not exist. |
| reserved_header | 400 | A header the send writes itself was supplied. Message-ID, Date, From, To, Cc, Bcc, Reply-To, Subject, and the DKIM signature are ours. In-Reply-To and References are not on the list: set those to thread a conversation. |
| invalid_header_name | 400 | A header name is not a valid token. |
| invalid_attachment | 400 | An attachment is missing filename or content_base64, or the base64 did not decode. |
| invalid_from_domain | 422 | The from domain is not verified on this workspace. Verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while testing. |
| domain_not_verified | 422 | The domain is added but its DNS records have not resolved yet. Call POST /v1/domains/:id/verify to see which are missing. |
| address_receive_only | 422 | That address can receive mail but was never allowed to send it. |
| address_paused | 422 | That sending address is paused. Resume it, or send from another address on the same domain. |
| sandbox_unavailable | 422 | The shared onboarding@emails.sh sender could not be used for this workspace. |
| sandbox_recipient_not_allowed | 422 | onboarding@emails.sh only reaches addresses belonging to this workspace. Verify a domain to reach anyone else. |
| owner_email_unverified | 422 | The workspace owner has not confirmed their address, so even the sandbox sender is closed. |
| recipient_suppressed | 422 | The address bounced, was marked as spam, or unsubscribed. The block is deliberate. Do not retry. Review it at https://emails.sh/dashboard/suppressions. |
| recipient_blocked_by_policy | 422 | A rule on this workspace forbids sending to that recipient. |
| topic_not_found | 404 | The topic key or id on the send does not exist on this workspace. |
| topic_opt_out | 422 | The recipient has switched that topic off. Nothing was sent, and that is the feature. |
| attachments_too_large | 413 | Attachments total more than 40 MB after base64 encoding. Host the file and link to it instead. |
| message_too_large | 413 | The attachments each fit but the assembled message is over 40 MB once headers and both body parts are counted. Drop an attachment or link to it instead. |
| send_at_invalid | 400 | send_at is not a time we can parse confidently. Use ISO 8601 with a Z offset, a relative offset such as "in 1 min", or a clock time such as "tomorrow at 9am", which is read as UTC. |
| send_at_in_past | 400 | send_at is in the past. Omit it to send now. |
| send_at_too_far | 400 | send_at is more than 30 days out. |
| idempotency_key_reused | 409 | The same idempotency_key was used within 24 hours with a different body. Use a fresh key for a different message. |
| idempotency_in_flight | 409 | A send with that idempotency_key is still being processed. Retry the identical request in a second or two. |
| empty_batch | 400 | POST /v1/emails/batch was given no emails. |
| batch_too_large | 400 | More than 100 emails in one batch call. Split it into chunks of 100. |
| quota_exhausted | 429 | The monthly or daily send allowance is used up. The next line carries the reset time. Upgrading clears it. |
| spend_cap_reached | 429 | Metered sending stopped at the spend cap set on the workspace. Raise it in billing. |
| address_daily_cap_reached | 429 | The daily cap on that sending address is used up. |
| duplicate_content_burst | 429 | The same body has already gone to a large number of distinct recipients today. Transactional sends carrying html are exempt. |
| velocity_spike | 429 | The workspace is sending far faster than its recent average and is throttled. Ramp up gradually. |
| rate_limited | 429 | 600 requests a minute per key, which is 10 a second. 30 a minute per IP without one. retry-after carries the seconds. |
| not_found | 404 | No object with that id on this workspace. Ids from another workspace read as missing. |
| internal_error | 500 | Ours. Retry once with the same idempotency_key, and if it repeats, quote the x-request-id to support. |

### What to retry

- **4xx other than 429**: Never retry unchanged. The request was wrong and it will be wrong the second time. Fix the body or the key.
- **429**: Wait the number of seconds in retry-after, then send once. Backing off exponentially on top of that is fine; retrying in a tight loop is what gets a key throttled harder.
- **5xx**: Retry with backoff, and pass idempotency_key so a retry that crossed with a success does not send twice.
- **A timeout with no response**: The send may or may not have happened. Retry with the same idempotency_key: that is exactly what it is for.

### The two you will actually hit

invalid_from_domain means the from domain is not verified on this workspace. Either verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while you are still testing. Nothing else clears it, and retrying will not.

sending_locked means nobody has confirmed the workspace owner's email address. It is the anti-abuse gate that stops a fresh signup being a free relay, and there is no card involved. Open the link in the confirmation email and it lifts immediately.

## Webhooks

https://emails.sh/docs/webhooks

A send returns before the email is delivered, so the interesting part happens afterwards. Register an endpoint and we POST every event about your mail to it as it happens: no polling, no cron, no waiting on GET /v1/emails/:id in a loop.

### Register an endpoint

POST /v1/webhooks:
```bash
curl -X POST https://emails.sh/v1/webhooks \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/hooks/emails",
    "events": ["email.delivered", "email.bounced", "email.complained"]
  }'
```

201 Created:
```json
{
  "id": "wh_01J9X9B4T2",
  "url": "https://acme.com/hooks/emails",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "secret": "whsec_2f8c1a4e9b7d0c53"
}
```

The secret is in that response and nowhere else. Store it before you close the terminal; a lost one is rotated, not recovered.

### Events

| Event | When it fires |
| --- | --- |
| email.sent | The receiving mail server accepted the message from us. |
| email.delivered | The receiving server confirmed delivery. Usually seconds after sent. |
| email.bounced | Refused. data carries bounce_type, bounce_subtype, and the remote diagnostic. |
| email.complained | The recipient marked it as spam. The address is suppressed automatically. Arrives hours or days later. |
| email.received | Inbound mail arrived on a domain of yours with an MX record published. The payload is documented in full below, and it carries the whole message. |
| email.filtered | Inbound mail dropped by one of your rules before it was stored. Fires instead of email.received, not as well as it. |
| domain.verified | A domain finished verification. The one event a setup script waits on. |
| workspace.throttled | Cold sending is being slowed. Clears on its own after a clean day. |
| workspace.paused | Every send is refused until a person has looked. |
| workspace.resumed | The throttle cleared. |
| automation.run.started | An automation run entered its first step. |
| automation.run.failed | A run failed somewhere inside it. |

That is the whole list. There is no email.opened and no email.clicked. An open is a pixel fetch that a privacy proxy often makes on the recipient behalf, so a stream of them is not something a receiver can act on; opens and clicks are counters on the message and rows in GET /v1/analytics instead. The three workspace events and the two automation events are account news rather than message news, so they reach only an endpoint that is not scoped to a single mailbox.

These events used to be named message.sent, message.delivered, and so on. Those spellings are still accepted when you register, and every read hands back the email.* name, so a subscription made years ago keeps matching and your handler only has to know one set of names.

### What arrives

A delivery:
```http
POST /your-endpoint HTTP/1.1
content-type: application/json
x-emailssh-event: email.delivered
x-emailssh-delivery-id: whd_9c1f4a2b7e0d4c58a1b6
x-emailssh-signature: t=1753440000,v1=9f2c...e1

{
  "id": "whd_9c1f4a2b7e0d4c58a1b6",
  "event": "email.delivered",
  "timestamp": "2026-07-28T09:14:02.117Z",
  "data": {
    "email_id": "em_01J9X8Q2K7Y4RN3M",
    "to": ["ada@example.com"],
    "subject": "Your receipt from Acme",
    "tags": { "campaign": "receipt" }
  }
}
```

The tags you set on the send are echoed on every event for it, which is what lets you route an event to the right tenant or template without a database lookup.

### Verify the signature

x-emailssh-signature is t=<unix seconds>,v1=<hex>. The digest is HMAC-SHA256 over "<timestamp>.<raw body>" with your endpoint secret. Check it against the raw bytes you received: re-serialising the parsed JSON reorders keys and produces a different string.

Node:
```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

// The raw bytes, not a re-serialised object: JSON.stringify(JSON.parse(x))
// is not always x, and a reordered key is a signature that will not match.
export function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 ?? '', 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Python:
```py
import hashlib
import hmac
import time


def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))

    # Reject anything older than five minutes: a valid signature replayed a
    # week later is still a valid signature.
    if abs(time.time() - int(parts["t"])) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{parts['t']}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, parts.get("v1", ""))
```

During a rotation the header carries one v1 per valid secret, so accept the delivery if any of them matches. That is what makes rotating a secret possible without dropping events.

### The delivery guarantee

At least once, unordered. Deduplicate on id, and sort by sequence. Those two sentences are the whole contract; the rest of this section is why.

At least once means the same event can arrive more than once. Retries, the held queue, the replay button, and dead-letter recovery all produce repeats on purpose, and a receiver we timed out on has usually already done the work. id is minted once per event and every copy carries it, in the body and in x-emailssh-delivery-id and x-request-id. Nothing else in the envelope is unique per event.

Unordered means two events about one message can arrive in either order, and routinely do. They are fanned out as independent queue jobs, delivered concurrently, and retried on independent curves, so a delivered that succeeded first overtakes a sent that needed one retry. Ordering them would mean serialising every workspace behind a single queue, and one slow receiver would then delay everybody. So every envelope carries sequence, and that is the field to sort on.

| Event | sequence |
| --- | --- |
| email.received | 1 |
| email.filtered | 1 |
| email.sent | 2 |
| email.delivered | 3 |
| email.bounced | 3 |
| email.complained | 4 |
| anything with no message behind it | 0 |

Higher is later. A receiver holding "this message is delivered" can drop a sent that turns up afterwards with a lower number, rather than moving the row backwards. Equal numbers are genuinely unordered with respect to each other and you must not infer an order from them: a bounce and a delivery are exclusive outcomes of the same step, and so are received and filtered. timestamp is when we raised the event, which is a good tiebreak and not a guarantee, because it comes from whichever worker raised it.

### Answer fast, and expect repeats

Reply 2xx within ten seconds. Anything else, including a timeout, is a failure. Do the work after you answer, not before.

| Attempt | Wait before it | Elapsed |
| --- | --- | --- |
| 1 | immediate | 0 |
| 2 | 5s | 5s |
| 3 | 5m | 5m 5s |
| 4 | 30m | 35m 5s |
| 5 | 2h | 2h 35m 5s |
| 6 | 5h | 7h 35m 5s |
| 7 | 10h | 17h 35m 5s |

Seven attempts over about 17 hours and 35 minutes, which is the same curve Resend publishes. After the seventh the event is abandoned, and an abandoned event is written into the delivery log with the rest, so GET /v1/webhooks/deliveries is the complete record rather than a record of the attempts that got a response. An endpoint that answers with its own Retry-After gets exactly that instead of the curve, capped at an hour. The numbers here come from GET /v1/webhooks, which publishes retry_delays_sec, max_attempts, and retry_window_sec, so read them from there rather than copying this table.

Turning an endpoint off takes two things at once: 20 consecutive failed attempts AND 18 hours with nothing accepted. Both, because a count on its own means something different on a busy workspace than on a quiet one. At seven attempts an event, 20 failures in a row is a couple of days of a quiet workspace and under a minute of a busy one riding through a 30-second deploy, and switching somebody off for a deploy is the wrong answer. The silence window is published as auto_disable_after_silence_sec on GET /v1/webhooks. A 410 Gone bypasses both and turns the endpoint off at once, because that is the receiver telling us the URL is retired. Events raised while it is off are held for 7 days, up to 500 of them, and past that the oldest are dropped. A test delivery that lands turns it back on and releases what is held.

Delivery is at-least-once, so the same event can arrive twice and every copy carries the same id in the body and in x-emailssh-delivery-id. Record the id and let a repeat fall through.

Deduplicate on the id:
```ts
// At-least-once: record the delivery id, and let a repeat fall straight through.
export async function handle(body: { id: string; event: string; data: unknown }) {
  if (await seen.has(body.id)) return new Response('ok');
  await seen.add(body.id, { ttlSeconds: 86_400 });

  switch (body.event) {
    case 'email.bounced':
      await markUndeliverable(body.data);
      break;
    case 'email.complained':
      await unsubscribe(body.data);
      break;
  }

  return new Response('ok');
}
```

### Inbound mail: the email.received payload

This is the one to read if you are moving off SendGrid Inbound Parse or Postmark inbound. It carries everything a handler needs for the common case, so it never has to call back for the body, the sender, or whether the mail is genuine, plus thread_id, which neither of them has and which is the reason to receive here rather than there.

email.received:
```json
{
  "id": "whd_4a71c0e8f2b94d6a8c13",
  "event": "email.received",
  "timestamp": "2026-07-28T09:14:02.117Z",
  "sequence": 1,
  "data": {
    "message_id": "msg_01J9XA2P4W8HKQ6Z",
    "thread_id": "thr_01J9XA2P4W8HKQ6Z",
    "mailbox": "support@acme.com",
    "from": "ada@example.com",
    "from_name": "Ada Lovelace",
    "to": ["support@acme.com"],
    "cc": ["billing@acme.com"],
    "subject": "Re: Your receipt from Acme",
    "snippet": "Thanks, but I was charged twice for July.",
    "text": "Thanks, but I was charged twice for July.\n\nAda",
    "html": "<p>Thanks, but I was charged twice for July.</p>",
    "date": "2026-07-28T09:13:58.000Z",
    "message_id_header": "<CAF9x2-abc123@mail.example.com>",
    "in_reply_to": "<receipt-8821@emails.sh>",
    "references": ["<receipt-8821@emails.sh>"],
    "list_id": null,
    "headers": {
      "from": "Ada Lovelace <ada@example.com>",
      "subject": "Re: Your receipt from Acme",
      "user-agent": "Mozilla Thunderbird"
    },
    "spam_verdict": "PASS",
    "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
    "automated": false,
    "attachments": [
      {
        "filename": "statement.pdf",
        "content_type": "application/pdf",
        "size": 88213,
        "url": "/v1/messages/msg_01J9XA2P4W8HKQ6Z/attachments/statement.pdf"
      }
    ],
    "inline_images": [
      {
        "filename": "signature.png",
        "content_id": "sig-1@example.com",
        "content_type": "image/png",
        "size": 4210
      }
    ]
  }
}
```

| Field | What it is |
| --- | --- |
| message_id | The stored message. Use it for GET /v1/messages/:id and for the attachment routes. |
| thread_id | The conversation, stable across the whole exchange. Reply in thread with POST /v1/messages/:id/reply-all. |
| mailbox | The address of yours it arrived at, which is how you route by product or by tenant. |
| from | The envelope sender, bare. |
| from_name | The display name off the From header, or null. |
| to, cc | Every address on the message, as arrays. bcc is not there because it never travels. |
| subject | As sent, already decoded from any MIME encoding. |
| snippet | The first line or so of the text body, for a list view. |
| text, html | Both bodies, whichever the sender provided. Either can be null; a message with only HTML is common. |
| date | The Date header as the sender wrote it. Not when we received it: timestamp on the envelope is that. |
| message_id_header | The sender own Message-ID, for matching against records you already keep. |
| in_reply_to, references | The threading chain, verbatim. thread_id is our answer to the same question and is easier. |
| list_id | Set on mail from a mailing list. Present is a strong reason not to auto-reply. |
| headers | Every header. Keys lowercased, repeats joined with a semicolon. |
| spam_verdict | What the receiving filter thought. |
| auth | SPF, DKIM and DMARC, each pass, fail, or none. A field rather than a header string, because code deciding whether to trust a message should read a value and not a regex. |
| automated | A bounce or a vacation auto-reply, decided from the headers. Check it before replying: an auto-responder answering an auto-responder is a loop that ends with both domains suppressed. |
| attachments | filename, content_type, size, and url. The bytes are fetched by id, not posted to you: a 25MB base64 body is a timeout rather than a feature. |
| inline_images | Images the HTML references with cid:, rather than files the sender attached. Each carries content_id, which is what the cid: in the HTML matches. |

Gate on auth.dmarc for "is this really from who it says". SPF alone fails legitimately on any forwarded mail, and DKIM alone says the signing domain is intact without saying it matches the From address. Anything that is neither pass nor fail is none, which means no policy was published or DNS was briefly unreachable, and treating that as a failure would reject a great deal of real mail.

Fetch an attachment with GET https://emails.sh{url} and your API key, or mail.inbound.attachment(messageId, filename) in any SDK. The same auth block is on GET /v1/messages and GET /v1/messages/:id, so a handler that missed a webhook can read it back.

### When it is not arriving

GET /v1/webhooks/deliveries shows what each attempt got back: the status code, the response body, and how long it took. That distinguishes an endpoint returning 500 from one that was never called, which is the first thing to establish.

Deliveries and a live test:
```bash
curl "https://emails.sh/v1/webhooks/deliveries?webhook_id=wh_01J9X9B4T2&limit=10" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Send a test event now and see exactly what your endpoint answered
curl -X POST https://emails.sh/v1/webhooks/deliveries \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"webhook_id": "wh_01J9X9B4T2"}'
```

A failed attempt that still has attempts owed to it carries a retry object: next_attempt, next_attempt_at, attempts_made, and attempts_remaining. It hangs off the newest attempt at an event only, because an older row is a thing that already happened. It is null on anything that succeeded and on an event that has run out of attempts.

next_attempt_at is derived from the retry curve, not stored. If your endpoint answered 429 with a Retry-After header, we honoured that instead, and the time shown for that event is wrong. Treat it as the schedule rather than as a promise.

Abandoned events are in that log too, with no status code and the error that ended them, so a gap in what your endpoint received has a row explaining it rather than nothing at all.

### Read, change, or remove one endpoint

An endpoint id addresses it directly. GET /v1/webhooks/:id reads one, PATCH changes it in place, and DELETE removes it along with anything held for it. The older PATCH /v1/webhooks?id= and DELETE /v1/webhooks?id= spellings still work and run the same code; the path form is the one to write now.

GET, PATCH, and DELETE /v1/webhooks/:id:
```bash
# One endpoint
curl https://emails.sh/v1/webhooks/wh_01J9X9B4T2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Fix a URL and widen the subscription, keeping the id and the secret
curl -X PATCH https://emails.sh/v1/webhooks/wh_01J9X9B4T2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/hooks/emails/v2",
    "events": ["email.sent", "email.delivered", "email.bounced", "email.complained"]
  }'

# Rotate the signing secret. The new one is in this response and nowhere else
curl -X PATCH https://emails.sh/v1/webhooks/wh_01J9X9B4T2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rotate_secret": true}'

# Remove it
curl -X DELETE https://emails.sh/v1/webhooks/wh_01J9X9B4T2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

Fields you leave out are left alone, and events replaces the subscription wholesale rather than adding to it, so send the whole list you want. active: true turns a disabled endpoint back on and resets its failure count, because switching it back on is the acknowledgement that whatever broke is fixed. A GET never returns the secret, at any scope: it signs every delivery, and it is handed back only when it is new, once on create and once on a rotation. During the grace window after a rotation both secrets sign every delivery, and previous_secret_valid_until in the rotation response is the deadline to redeploy your receiver by.

An id that is not on this workspace answers 404 rather than 403, whether it never existed or belongs to somebody else, because a 403 would confirm the id is real elsewhere. A PATCH body that changes nothing is 400 nothing_to_update.

#### `webhooks.list`

`{ }`

Registered endpoints and the events each is subscribed to. Signing secrets are not listed.

Returns: { webhooks: Webhook[] }

#### `webhooks.create`

`{ url: string, events?: string[], headers?: Record<string, string> }`

Register an endpoint. The signing secret comes back once, in this response.

| Parameter | Type | Required |
| --- | --- | --- |
| url | `string` | yes |
| events | `string[]` | no |
| headers | `Record<string, string>` | no |

Returns: { id, url, events, secret }

#### `webhooks.get`

`{ id: string }`

One endpoint, with its events, whether it is active, and its failure counters. The signing secret is not in a read, at any scope.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Webhook

#### `webhooks.update`

`{ id: string, url?: string, events?: string[], active?: boolean, headers?: Record<string, string>, rotate_secret?: boolean }`

Change an endpoint in place. Fields you leave out are left alone.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| url | `string` | no |
| events | `string[]` | no |
| active | `boolean` | no |
| headers | `Record<string, string>` | no |
| rotate_secret | `boolean` | no |

Returns: Webhook, plus secret and previous_secret_valid_until when rotate_secret was set

#### `webhooks.deliveries`

`{ webhook_id?: string, limit?: number }`

What each attempt got back: status code, response body, and duration. This is how you tell a broken endpoint from a missing event.

| Parameter | Type | Required |
| --- | --- | --- |
| webhook_id | `string` | no |
| limit | `number` | no |

Returns: { deliveries: Delivery[] }

#### `webhooks.delete`

`{ id: string }`

Remove an endpoint. Queued deliveries for it are dropped.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { deleted: id }

## Audiences and contacts

https://emails.sh/docs/audiences

Most of what emails.sh sends is transactional: one email, caused by one thing a person did. An audience is the other case, a list of people you mail together: a product changelog, a release note, an occasional announcement.

You send to one with a broadcast, and you narrow one with a segment. Both of those are their own chapters: /docs/broadcasts and /docs/segments. This page is the list itself, who is on it, and what you know about each of them.

What is deliberately absent is a way to mail people who never asked. Every address here has to have come from somewhere you can point at, imports check the suppression list before they write, and an audience whose complaint rate climbs pauses the workspace. Cold outreach is not a feature we forgot to build.

### Create an audience

POST /v1/audiences:
```bash
curl -X POST https://emails.sh/v1/audiences \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Product updates", "description": "Monthly changelog"}'
```

### Membership, not contact

Two ids matter here and confusing them is the most common mistake against this API. A contact is a person on the workspace and has a contact_id. A membership is that person on one list, and it has its own id. Every route under /v1/audiences/:id/contacts/:member takes the membership id, and nothing there accepts a contact id or an email address.

GET /v1/audiences/:id/contacts, and id is the membership id:
```json
{
  "contact_count": 2,
  "subscribed_count": 1,
  "contacts": [
    {
      "id": "6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480",
      "contact_id": "b4c0e21f-7d69-4a55-8e13-2f9a6c081d77",
      "email": "ada@example.com",
      "status": "subscribed",
      "attributes": { "first_name": "Ada", "plan": "pro" },
      "subscribed_at": "2026-07-02T11:20:04.118Z",
      "unsubscribed_at": null
    },
    {
      "id": "c8a95b70-1e34-4d29-b6f5-0a72e4c31d96",
      "contact_id": "5d3f8a12-6b04-47ce-9a81-cf20e5b7a344",
      "email": "grace@example.com",
      "status": "unsubscribed",
      "attributes": { "first_name": "Grace" },
      "subscribed_at": "2026-06-14T08:02:51.663Z",
      "unsubscribed_at": "2026-07-19T16:45:12.907Z"
    }
  ]
}
```

### Import

POST /v1/audiences/:id/contacts is a bulk import rather than a single add. It takes a bare array, a { "contacts": [] } envelope, a { "data": [] } envelope, or one object, and it takes CSV when the Content-Type says csv. Each row is an email, optional attributes, an optional status, and optional tags. Up to 10000 rows and 8 MB per call, past which you get 413 too_many_contacts or 413 import_too_large.

POST /v1/audiences/:id/contacts:
```bash
curl -X POST https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      {
        "email": "ada@example.com",
        "attributes": { "first_name": "Ada", "plan": "pro" },
        "status": "subscribed",
        "tags": ["beta"]
      },
      {
        "email": "grace@example.com",
        "attributes": { "first_name": "Grace", "plan": "free" },
        "tags": ["beta", "waitlist"]
      }
    ]
  }'
```

201 Created:
```json
{
  "created": 1,
  "updated": 1,
  "unchanged": 0,
  "duplicates": 0,
  "tags_added": 3,
  "imported": 2,
  "held_back": 0,
  "skipped": 0,
  "held": [],
  "errors": []
}
```

created counts memberships this call actually wrote, so it is the number of people the list gained. A row naming somebody who is already a member is counted in duplicates instead, and nothing is written for it: that is what a second spelling of one address in the same file looks like, and it is how you can tell a re-upload added nobody rather than trusting that it did not.

An address already on the suppression list is written as cleaned rather than subscribed and counted in held_back, whatever status the row asked for. That is the whole point: an import cannot resurrect somebody who bounced or complained, and the count tells you it happened instead of hiding it.

Add dry_run=true to the query string to find out what would happen without writing anything. It answers 200 with would_create, would_update, would_leave_unchanged, would_hold_back, the columns it found, and a sample of parsed rows, which is what you want in front of a person before importing a CSV somebody exported from a spreadsheet.

Check a CSV before importing it:
```bash
# A CSV whose columns are not our names. Map them in the query string.
curl -X POST "https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts?dry_run=true&mapping.email=Email%20Address&mapping.attr.First%20Name=first_name&mapping.ignore=Internal%20Notes" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @subscribers.csv
```

- **mapping.email**: Which column holds the address. Required when no column is called email or email_address.
- **mapping.status**: Which column holds the subscription state.
- **mapping.tags**: Which column holds tags, as a comma-separated string.
- **mapping.attr.<column>**: Store that column as an attribute under the name you give. Repeat it once per column.
- **mapping.ignore**: Drop a column entirely. Repeat it once per column.

### Attributes are merge fields here

The attributes on a membership are per-list merge fields, and they are what a broadcast substitutes into {{ first_name }}. They are a different store from the workspace-level contact attributes at /v1/contacts/:id/attributes, which is what segments and automations read. /docs/contact-data is the chapter about the difference, and it is worth reading before you decide where to put a field.

### Unsubscribes

Record an unsubscribe rather than deleting the membership. A deleted row can be re-added by the next import and mailed again, which is how a company ends up in a complaint report. A membership set to unsubscribed stays unsubscribed, and DELETE on a membership records nothing.

PATCH the membership, not the contact:
```bash
curl -X PATCH https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subscribed": false}'
```

A broadcast filed under a topic gets its List-Unsubscribe header and its one-click opt-out built for it. If you are sending to an audience some other way, put the header on yourself: Gmail and Yahoo both require one-click unsubscribe on bulk mail, and the alternative to a working link is the spam button.

### Statuses

| Status | What it means |
| --- | --- |
| subscribed | Mailable. The only status a broadcast sends to. |
| pending | Added under double opt-in and has not confirmed yet. Not mailed except by the confirmation itself. See /docs/double-opt-in. |
| unsubscribed | They asked to stop. Kept as a row so a later import cannot undo it. |
| cleaned | The address bounced, complained, or was already suppressed when it was imported. Not mailable, and not something an import can change back. |

- `GET /v1/audiences` Audiences on the workspace.
- `POST /v1/audiences` { name, description? } creates one.
- `GET /v1/audiences/:id` One audience, with its double opt-in setting and where that setting came from.
- `PATCH /v1/audiences/:id` { name?, description?, require_double_opt_in?, confirmation_subject?, confirmation_body? }
- `DELETE /v1/audiences/:id` Soft delete an audience.
- `GET /v1/audiences/:id/contacts` Members. ?status=&limit=&offset=. id on each row is the membership id.
- `POST /v1/audiences/:id/contacts` Bulk import of { email, attributes?, status?, tags? } rows, as JSON or CSV. ?dry_run=true reports without writing.
- `GET /v1/audiences/:id/contacts/:member` One membership, by membership id.
- `PATCH /v1/audiences/:id/contacts/:member` { attributes?, subscribed?, status? } on one membership.
- `DELETE /v1/audiences/:id/contacts/:member` Remove one membership. It records no unsubscribe.
- `POST /v1/audiences/:id/contacts/:member/confirm` Send the double opt-in confirmation email. Nothing else ever sends it.

#### `audiences.list`

`{ }`

Audiences on the workspace. An audience is a named list of contacts with their subscription state.

Returns: { audiences: Audience[] }

#### `audiences.create`

`{ name: string }`

Create an audience.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |

Returns: { id, name }

#### `audiences.members`

`{ audience_id: string, status?: string, limit?: number, offset?: number }`

Members of one audience. Each row carries a membership id, which is the id every other member route takes, and a contact_id, which is the workspace-level contact behind it.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| status | `string` | no |
| limit | `number` | no |
| offset | `number` | no |

Returns: { contact_count, subscribed_count, contacts: Member[] }

#### `audiences.import`

`{ audience_id: string, contacts: { email, attributes?, status?, tags? }[], dry_run?: boolean }`

Bulk import into an audience. Up to 10000 rows and 8 MB per call. An address already on the suppression list lands as cleaned rather than subscribed and is counted in held_back.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| contacts | `{ email, attributes?, status?, tags? }[]` | yes |
| dry_run | `boolean` | no |

Returns: { created, updated, unchanged, duplicates, tags_added, imported, held_back, skipped, held[], errors[] }

#### `audiences.updateMember`

`{ audience_id: string, member: string, attributes?: Record<string, string>, subscribed?: boolean, status?: string }`

Change one membership. Recording an unsubscribe here is what keeps the address from being mailed by the next broadcast.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| member | `string` | yes |
| attributes | `Record<string, string>` | no |
| subscribed | `boolean` | no |
| status | `string` | no |

Returns: Member

#### `audiences.removeMember`

`{ audience_id: string, member: string }`

Remove one membership from an audience. It does not delete the contact, and it does not record an unsubscribe: set status to unsubscribed for that.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| member | `string` | yes |

Returns: { deleted: id }

Sending to an audience is POST /v1/broadcasts/:id/send. Sending one transactional message is still POST /v1/emails, and the two are separate on purpose: a broadcast checks topics, suppression, and segment membership per recipient before anything leaves.

## Tags and attributes

https://emails.sh/docs/contact-data

There are two stores of facts about a person here and they are not the same store. Putting a value in the wrong one is the mistake this page exists to prevent, because the symptom is a segment that matches nobody or a broadcast that greets everybody as blank.

| Store | What it is for |
| --- | --- |
| Contact attributes, at /v1/contacts/:id/attributes | Workspace-level facts about a person: their plan, their signup date, their company size. One set per contact, whatever lists they are on. This is what segments and automations read. |
| Membership attributes, on an audience member | Per-list merge fields, substituted into {{ first_name }} when a broadcast renders. One set per membership, so the same person can carry different values on two lists. |
| Tags, at /v1/contacts/:id/tags | Labels rather than values. Workspace level, like contact attributes. Adding or removing one fires an automation event. |

The short version: if a segment or an automation has to read it, it is a contact attribute or a tag. If a broadcast has to print it, it is a membership attribute. A first name that a broadcast greets people by and a segment never filters on belongs only on the membership, and a plan tier that a segment filters on and no email ever prints belongs only on the contact.

Nothing copies between the two automatically. A field you need in both places is written in both places.

### Contact attributes

A merge patch: the names you send are written, the names you leave out are untouched, and null clears one. Between 1 and 100 names per call. Values are stored as text, which is why a segment comparing them numerically checks that both sides parse first.

PATCH /v1/contacts/:id/attributes:
```bash
curl -X PATCH https://emails.sh/v1/contacts/b4c0e21f-7d69-4a55-8e13-2f9a6c081d77/attributes \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "attributes": {
      "plan": "pro",
      "seats": "12",
      "renewal_date": "2027-03-01",
      "trial_source": null
    }
  }'
```

200 OK:
```json
{
  "attributes": {
    "plan": "pro",
    "seats": "12",
    "renewal_date": "2027-03-01"
  },
  "changed": ["plan", "seats", "renewal_date", "trial_source"]
}
```

changed lists every name the call actually altered, cleared names included, which is what an automation with an attribute.changed trigger watches. A value written identically to what was already there is not a change and does not appear.

A date attribute in YYYY-MM-DD form is what a date.attribute automation trigger reads, which is how a renewal reminder gets sent a week before renewal_date without a cron job of your own. See /docs/automations.

### Tags

A tag is a label with no value. Between 1 and 64 characters after trimming, no comma and no line break, compared without case, and stored lowercased, so "VIP" and "vip" are one tag and adding the second does nothing.

Add and remove:
```bash
# Add. The body is either a "tags" array or a single "tag" string.
curl -X POST https://emails.sh/v1/contacts/b4c0e21f-7d69-4a55-8e13-2f9a6c081d77/tags \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["vip", "beta"]}'

# Remove one. 404 if the contact does not hold it.
curl -X DELETE "https://emails.sh/v1/contacts/b4c0e21f-7d69-4a55-8e13-2f9a6c081d77/tags?tag=beta" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

Every real change queues an automation event, tag.added or tag.removed. Adding a tag the contact already holds is not a real change and queues nothing, so a nightly sync that reasserts the same tags does not fire a welcome sequence every night. The queue is swept every five minutes, so a tag-triggered automation starts within that window rather than instantly.

### Membership attributes

These arrive with an import, or one at a time with a PATCH on the membership. They are what a broadcast substitutes, and they are keyed by the membership id rather than the contact id.

PATCH the membership:
```bash
curl -X PATCH https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"attributes": {"first_name": "Ada", "city": "London"}}'
```

A merge field the audience does not supply renders as nothing, so a body reading "Hello {{ first_name }}," greets somebody as "Hello ,". Check with the broadcast preview before you send: merge_fields tells you which fields are supplied and which are not. See /docs/broadcasts.

### Will this person receive anything

One call answers it, across every address the contact holds: whether each is suppressed, which audiences it is on, and which topics it has answered. This is the call to put behind a support screen, so somebody asking "why did Ada not get the email" gets an answer rather than a database session.

GET /v1/contacts/:id/subscriptions:
```bash
curl https://emails.sh/v1/contacts/b4c0e21f-7d69-4a55-8e13-2f9a6c081d77/subscriptions \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "contact_id": "b4c0e21f-7d69-4a55-8e13-2f9a6c081d77",
  "subscriptions": [
    {
      "email": "ada@example.com",
      "suppressed": false,
      "audiences": [
        { "id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33", "name": "Product updates", "status": "subscribed" }
      ],
      "topics": [
        { "key": "product-updates", "subscribed": true, "stated": true },
        { "key": "billing", "subscribed": true, "stated": false }
      ]
    }
  ]
}
```

stated says whether the person answered that topic themselves. false means nobody has said anything and the topic default is deciding, which is the distinction that matters when somebody claims they never signed up.

- `GET /v1/contacts` ?q=&lookup=&limit=. Accept: text/vcard returns a .vcf instead of JSON.
- `POST /v1/contacts` Field mode, or { vcard } for up to 1000 cards at once.
- `GET /v1/contacts/:id` One contact.
- `PATCH /v1/contacts/:id` Change a contact.
- `DELETE /v1/contacts/:id` Remove a contact.
- `GET /v1/contacts/duplicates` Likely duplicate pairs. POST merges { survivor_id, loser_id }.
- `GET /v1/contacts/:id/tags` Tags on a contact.
- `POST /v1/contacts/:id/tags` { tags } or { tag }. Each real change queues a tag.added automation event.
- `DELETE /v1/contacts/:id/tags` ?tag=vip removes one, and queues tag.removed.
- `GET /v1/contacts/:id/attributes` Workspace-level attributes on a contact.
- `PATCH /v1/contacts/:id/attributes` Merge patch. null clears one name.
- `GET /v1/contacts/:id/subscriptions` Every address, whether it is suppressed, and what it is subscribed to.

#### `contacts.tags`

`{ id: string, tags?: string[] }`

Read or add tags on a contact. Tags are 1 to 64 characters, hold no comma or newline, are compared without case, and are stored lowercased.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| tags | `string[]` | no |

Returns: { tags, added }

#### `contacts.attributes`

`{ id: string, attributes?: Record<string, string | null> }`

Workspace-level facts about a contact, readable by every segment and automation. These are not the per-list merge fields a broadcast substitutes.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| attributes | `Record<string, string | null>` | no |

Returns: { attributes, changed[] }

#### `contacts.subscriptions`

`{ id: string }`

Every address on a contact, whether it is suppressed, and the audiences and topics it is subscribed to. The one call that answers "will this person receive anything".

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { contact_id, subscriptions[] }

GET /v1/contacts with Accept: text/vcard answers with a .vcf, and POST /v1/contacts takes { vcard } for up to 1000 cards at once. That is the import path for an address book rather than a mailing list.

## Double opt-in

https://emails.sh/docs/double-opt-in

Read this first. The confirmation email is sent only by POST /v1/audiences/:id/contacts/:member/confirm. Nothing sends it for you. Turning double opt-in on and then adding somebody leaves them sitting in pending forever, unmailed and unconfirmed, until your code makes that call.

Double opt-in means a new member is not mailable until they click a link in an email confirming they meant it. It is the difference between a list you can defend and a list somebody typed a stranger's address into. Here it is two pieces: a setting that decides what status a new member lands in, and a call that sends the confirmation.

### Turn it on

There is a workspace default, and each audience can override it. The per-audience setting is three-valued: true requires it, false does not, and null inherits the workspace default. GET on an audience tells you both the answer and where the answer came from.

PATCH /v1/audiences/:id:
```bash
curl -X PATCH https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "require_double_opt_in": true,
    "confirmation_subject": "Confirm your subscription to Acme updates",
    "confirmation_body": "<p>Hello, click to confirm you want Acme product updates: <a href=\"{{ confirm_url }}\">confirm</a>. The link works for {{ ttl_days }} days.</p>"
  }'
```

double_opt_in_source says which setting decided:
```json
{
  "id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
  "name": "Product updates",
  "description": "Monthly changelog",
  "contact_count": 1879,
  "subscribed_count": 1842,
  "require_double_opt_in": true,
  "double_opt_in_source": "audience",
  "created_at": "2026-06-01T10:00:00.000Z"
}
```

- **{{ confirm_url }}**: The signed link. A body without it is a confirmation email nobody can act on.
- **{{ audience_name }}**: What they are confirming, so the email says what it is about.
- **{{ ttl_days }}**: How long the link lasts, which is 30 days.
- **{{ email }}**: The address being confirmed.

### What happens on add

With it on, a new member lands in pending rather than subscribed, and a pending member is not mailed by a broadcast. The one exception is deliberate: a row that explicitly says status "subscribed" is taken at its word, which is how a list that was already confirmed somewhere else is migrated without asking everybody again.

That exception is a loaded gun. Use it for an import out of a system that genuinely held confirmations, and not for a spreadsheet.

### Send the confirmation

Import or add the person, read back their membership id, and call confirm. That is the step nothing does for you.

POST /v1/audiences/:id/contacts/:member/confirm:
```bash
curl -X POST https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480/confirm \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

202 Accepted:
```json
{
  "membership_id": "6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480",
  "email": "ada@example.com",
  "sent": true,
  "status": "pending",
  "confirm_url": "https://emails.sh/c/confirm/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480?e=ada%40example.com&t=9f2c4e1b7a05"
}
```

The whole signup handler, then, is three calls: import the address, take the membership id off the result, and confirm it. Wire it once and forget it.

A signup handler that actually confirms:
```ts
const AUDIENCE = '2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33';

const headers = {
  Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
  'Content-Type': 'application/json'
};

export async function subscribe(email: string, firstName: string) {
  // 1. Import. Under double opt-in this lands the member in "pending".
  await fetch(`https://emails.sh/v1/audiences/${AUDIENCE}/contacts`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ contacts: [{ email, attributes: { first_name: firstName } }] })
  });

  // 2. Find the membership id. It is not the contact id and not the address.
  const listed = await fetch(
    `https://emails.sh/v1/audiences/${AUDIENCE}/contacts?status=pending&limit=1000`,
    { headers }
  );
  const { contacts } = (await listed.json()) as {
    contacts: { id: string; email: string }[];
  };
  const member = contacts.find((c) => c.email === email);
  if (!member) throw new Error(`${email} is not pending on this audience`);

  // 3. Send the confirmation. Nothing else ever does.
  const confirmed = await fetch(
    `https://emails.sh/v1/audiences/${AUDIENCE}/contacts/${member.id}/confirm`,
    { method: 'POST', headers }
  );

  if (!confirmed.ok) {
    const body = (await confirmed.json()) as { error?: { code?: string } | string };
    const code = typeof body.error === 'string' ? body.error : body.error?.code;
    throw new Error(`confirmation refused: ${code ?? confirmed.status}`);
  }
}
```

### The link

The token is a signed HMAC rather than a row, so nothing has to be stored and nothing expires early. It lasts 30 days and a plain GET on the link confirms, because a mail client that prefetches a link is a fact of life and a confirmation is not a destructive action.

A membership may be sent at most 3 confirmations. Past that the call answers 422 confirmation_refused rather than letting a retry loop become a way to mail somebody repeatedly.

| Refusal | What it means |
| --- | --- |
| 422 confirmation_refused | Already confirmed, already unsubscribed, suppressed, or past the third send. |
| 422 no_sending_address | The workspace has no verified address to send the confirmation from. Verify a domain first. |
| 500 confirmation_unavailable | The send itself failed. Retry. |

### When to bother

- **A public signup form**: Always. It is the only thing standing between your list and a bored person typing in addresses that are not theirs.
- **A checkbox during account creation**: Usually not. You already sent them a verification email and they already proved they own the address.
- **An imported list**: Depends on where it came from. If the source held real confirmations, migrate them as subscribed. If it came from a spreadsheet, confirm them, and expect a smaller list afterwards than you hoped for.

Double opt-in is about consent to be on a list. It is a different question from a topic opt-out, which is consent to keep receiving one category of mail. Most lists want both. See /docs/topics.

## Segments

https://emails.sh/docs/segments

A segment is a set of rules, not a stored list. Membership is computed when you ask, so a segment is never stale and nothing has to run overnight to refresh it. Point a broadcast at one with segment_id and the send goes to the intersection of the audience and the rules.

A segment either belongs to an audience or to the workspace. One with an audience_id can ask about membership state and join dates; one without cannot, because outside an audience there is nothing for those questions to mean.

### Create one

POST /v1/segments:
```bash
curl -X POST https://emails.sh/v1/segments \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Active pro users who never clicked",
    "audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
    "match": "all",
    "rules": [
      { "field": "status", "op": "eq", "value": "subscribed" },
      { "field": "attribute", "op": "eq", "name": "plan", "value": "pro" },
      { "field": "tag", "op": "not_has", "value": "churned" },
      { "field": "clicked", "op": "never" }
    ]
  }'
```

201 Created:
```json
{
  "id": "7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2",
  "name": "Active pro users who never clicked",
  "description": null,
  "audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
  "match": "all",
  "rules": [
    { "field": "status", "op": "eq", "value": "subscribed" },
    { "field": "attribute", "op": "eq", "name": "plan", "value": "pro" },
    { "field": "tag", "op": "not_has", "value": "churned" },
    { "field": "clicked", "op": "never" }
  ],
  "describes": "subscribed, plan is pro, not tagged churned, and never clicked",
  "member_count": 412,
  "counted_at": "2026-07-28T09:14:01.882Z",
  "created_at": "2026-07-28T09:14:01.882Z"
}
```

describes is the rule list as a sentence, generated from the rules rather than typed by anybody. Show it next to a segment in your own UI and a person can check the filter without reading JSON.

### The rule grammar

match is all or any, up to 20 rules, and there is no nesting. That ceiling is not an oversight: a filter that needs a nested boolean is a query, and a query belongs in your own database where you can test it.

| field | Operators, and what the rule takes |
| --- | --- |
| tag | has, not_has. value is the tag, compared without case. |
| attribute | eq, ne, contains, starts_with, gt, lt, exists, not_exists. name is the attribute, value is what to compare it against, and exists and not_exists take no value. |
| status | eq, ne. value is subscribed, unsubscribed, pending, or cleaned. Needs an audience_id. |
| joined | before, after with an ISO date; within_days, not_within_days with a number from 1 to 3650. Needs an audience_id. |
| opened | within_days, not_within_days with days from 1 to 3650; ever, never with no argument. |
| clicked | The same four operators as opened. |

- **attribute ne matches an absent attribute**: A contact with no plan attribute at all satisfies plan ne pro. That is usually what you want and occasionally a surprise, so pair it with an exists rule when it is not.
- **gt and lt compare numbers only when both sides are numbers**: Attribute values are stored as text. If either side does not parse as a number the rule does not match, rather than falling back to comparing strings and quietly matching the wrong people.
- **Rules are replaced, never merged**: PATCH takes the whole rules array and writes it over the old one. Send every rule you are keeping, not just the one you are changing.
- **Opens undercount**: An opened rule reads tracking pixels, and image blocking means it is a floor rather than a count. never on opened matches people who read every message in a client that blocks images. clicked is the sturdier signal.

### Count and read it

member_count comes back cached, with counted_at saying when it was computed. Pass count=live to recompute it now, which is what you want on a screen where somebody is about to press send.

Count, then list:
```bash
# Recompute the count now
curl "https://emails.sh/v1/segments/7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2?count=live" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Who is in it, mailable only, one page at a time
curl "https://emails.sh/v1/segments/7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2/members?mailable=true&limit=500" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

The members list is cursor paged: take next_after from a response and pass it as after on the next call. It stops when next_after comes back null. mailable=true drops anybody who is not subscribed or who is suppressed, which is the set a broadcast would actually reach.

### Use it on a broadcast

Narrow a draft to a segment:
```bash
curl -X PATCH https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"segment_id": "7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2"}'
```

The segment is evaluated when the send starts, not when you attach it. Somebody who stopped matching in between is not mailed, which is the behaviour you want from a rule like "not tagged churned".

- `GET /v1/segments` Segments. ?audience_id= narrows to one audience.
- `POST /v1/segments` { name, audience_id?, match?, rules? } creates one.
- `GET /v1/segments/:id` ?count=live recomputes member_count instead of reading the cached one.
- `PATCH /v1/segments/:id` Rules are replaced wholesale, never merged.
- `DELETE /v1/segments/:id` Remove a segment. Contacts are untouched.
- `GET /v1/segments/:id/members` ?mailable=true&limit=&after= over who it matches now.

#### `segments.create`

`{ name: string, audience_id?: string, match?: "all" | "any", rules?: Rule[], description?: string }`

A saved filter over contacts. Membership is computed when it is read rather than stored, so a segment is never stale.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |
| audience_id | `string` | no |
| match | `"all" | "any"` | no |
| rules | `Rule[]` | no |
| description | `string` | no |

Returns: { id, name, describes, member_count }

#### `segments.get`

`{ id: string, count?: string }`

One segment, its rules, and the sentence describing them.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| count | `string` | no |

Returns: Segment

#### `segments.update`

`{ id: string, match?: "all" | "any", rules?: Rule[] }`

Change a segment. Send the whole rule list every time, including the rules you are keeping.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| match | `"all" | "any"` | no |
| rules | `Rule[]` | no |

Returns: Segment

#### `segments.members`

`{ id: string, mailable?: boolean, limit?: number, after?: string }`

Who a segment currently matches, as a cursor-paged list.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| mailable | `boolean` | no |
| limit | `number` | no |
| after | `string` | no |

Returns: { segment_id, total, next_after, members[] }

Deleting a segment answers with the scope it had, audience or workspace. It removes the filter and touches no contact.

## Broadcasts

https://emails.sh/docs/broadcasts

A broadcast is one message sent to every mailable member of an audience, optionally narrowed by a segment. It is the counterpart to POST /v1/emails: that endpoint sends one email because one person did one thing, and this one sends the same message to a list on purpose.

A broadcast is a draft first and a send second, and the two are separate calls. That is deliberate: creating one is cheap and reversible, sending one is neither. Between the two you can preview it, test it to yourself, and read back the list of problems that stop it from going.

### Create a draft

from is the only required field. Everything else can be filled in later with PATCH, and the response tells you what is still missing rather than refusing until it is complete.

POST /v1/broadcasts:
```bash
curl -X POST https://emails.sh/v1/broadcasts \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "July changelog",
    "from": "Acme <hello@acme.com>",
    "reply_to": "support@acme.com",
    "audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
    "topic_id": "8c40e6b2-95a1-4f3d-b708-1e5d72c4a069",
    "subject": "What changed in July",
    "html": "<p>Hello {{ first_name }}, three new things this month.</p>",
    "text": "Hello {{ first_name }}, three new things this month.",
    "track_opens": true,
    "track_clicks": true
  }'
```

201 Created:
```json
{
  "id": "d1f6c48a-2b07-4e93-85ca-7f30b9d16e52",
  "name": "July changelog",
  "status": "draft",
  "audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
  "segment_id": null,
  "topic_id": "8c40e6b2-95a1-4f3d-b708-1e5d72c4a069",
  "from": "Acme <hello@acme.com>",
  "reply_to": "support@acme.com",
  "subject": "What changed in July",
  "track_opens": true,
  "track_clicks": true,
  "scheduled_at": null,
  "sent_at": null,
  "failure_reason": null,
  "created_at": "2026-07-28T09:14:01.882Z",
  "stats": null,
  "ready": true,
  "problems": []
}
```

ready and problems are the pair worth reading. problems is a list of sentences, and it is empty exactly when ready is true. There are four of them and they are the only reasons a broadcast will not send.

| Problem | What to do |
| --- | --- |
| a broadcast needs an audience to send to | Set audience_id. A segment on its own is not a recipient list. |
| a broadcast needs a from address on a verified domain | Verify the domain at /docs/domains. The sandbox sender is refused here with 422 sandbox_not_allowed_for_broadcasts, because a shared address must not carry a list send. |
| a broadcast needs a subject | Set subject. |
| a broadcast needs an html body, a text body, or a template | Set html, text, or template_id. |

### Merge fields

The body takes {{ name }} substitution, and the values come from the attributes on each audience membership. Those are per-list merge fields, and they are a different store from contact attributes: /docs/contact-data is the chapter about the difference. The syntax is the same one templates use, with no conditionals and no loops.

Preview tells you which fields the audience actually supplies before you find out the hard way. supplied is false for a field that appears in the body and is missing from the sample of members it checked.

Render it for one real member:
```bash
curl "https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/preview?email=ada@example.com" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "id": "d1f6c48a-2b07-4e93-85ca-7f30b9d16e52",
  "rendered_for": "ada@example.com",
  "from": "Acme <hello@acme.com>",
  "reply_to": "support@acme.com",
  "subject": "What changed in July",
  "html": "<p>Hello Ada, three new things this month.</p>",
  "text": "Hello Ada, three new things this month.",
  "headers": { "List-Unsubscribe": "<https://emails.sh/p/unsub/8c40e6b2>" },
  "merge_fields": [
    { "field": "first_name", "supplied": true },
    { "field": "plan", "supplied": false }
  ],
  "audience_sample_size": 200
}
```

### Preview before you save anything

A body still being written does not need a draft behind it. POST /v1/broadcasts/preview takes the content in the request and renders it through the same function the send uses, so what you see is what would go out. Nothing is written, nothing is sent, and id comes back null because there is no broadcast to fetch later.

Pass audience_id to render against a real member of that audience, and email to pick which one. Leave audience_id out and the recipient is the placeholder someone@example.com with no attributes, so every merge field comes back supplied: false. That is the honest answer: nothing was checked against a real list.

Render a body that has never been saved:
```bash
# The key is the one from https://emails.sh/dashboard/keys, or from
# POST /v1/api-keys with a key you already have.
export EMAILSSH_API_KEY="esh_live_your_key"

curl https://emails.sh/v1/broadcasts/preview \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subject":"What changed in {{ month }}","html":"<p>Hello {{ first_name }}, three new things.</p>","from":"Acme <hello@acme.com>"}'
```

200 OK, with no audience: every merge field renders empty:
```json
{
  "id": null,
  "rendered_for": "someone@example.com",
  "from": "Acme <hello@acme.com>",
  "reply_to": null,
  "subject": "What changed in ",
  "html": "<p>Hello , three new things.</p>",
  "text": "Hello , three new things.",
  "headers": { "List-Unsubscribe": "<https://emails.sh/p/unsub/preview>" },
  "merge_fields": [
    { "field": "month", "supplied": false },
    { "field": "first_name", "supplied": false }
  ],
  "audience_sample_size": 0
}
```

audience_sample_size is 0 exactly when the placeholder was used. Add "audience_id":"<audience-id>" to the same request and both numbers change: the render is done against a real member and supplied starts telling you something.

### Test it to yourself

Up to 5 of your own addresses, rendered the way a recipient would get it. It does not move the broadcast out of draft and it does not count anybody as sent.

POST /v1/broadcasts/:id/test:
```bash
curl -X POST https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/test \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": ["you@acme.com", "colleague@acme.com"]}'
```

### Send it

With no body it goes now. With scheduled_at it is booked, and the timestamp must be strictly in the future and no more than 30 days out.

POST /v1/broadcasts/:id/send:
```bash
# Now
curl -X POST https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/send \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Or at a time you pick
curl -X POST https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/send \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scheduled_at": "2026-08-01T09:00:00Z"}'
```

Sending now:
```json
{
  "id": "d1f6c48a-2b07-4e93-85ca-7f30b9d16e52",
  "status": "sending",
  "queued": 1842,
  "batches": 19,
  "skipped": 37,
  "recipients": 1879
}
```

skipped is the number that deserves a look. Those are members the send refused to touch: suppressed addresses, people who opted out of the topic, and anybody not in the subscribed state. Each one has a reason on its recipient row.

| Refusal | What happened |
| --- | --- |
| 422 broadcast_incomplete | problems is not empty. Read it and fix what it names. |
| 409 broadcast_already_sending | A send is already in flight. This is what stops a double-clicked button becoming two sends. |
| 409 broadcast_not_schedulable | It is past draft, so it cannot be booked for later. |
| 409 broadcast_not_editable | PATCH on something that is no longer a draft. |
| 409 broadcast_not_cancellable | It has already finished sending. |
| 422 sandbox_not_allowed_for_broadcasts | from resolved to onboarding@emails.sh. Verify a domain first. |

### Statuses

| Status | What it means |
| --- | --- |
| draft | Editable. Nothing has been sent and nothing is booked. |
| scheduled | Booked for scheduled_at. Cancellable. |
| sending | In flight, going out in batches. |
| sent | Every batch has been handed off. Delivery events keep arriving afterwards. |
| cancelled | Called off. Anything already handed to the mail servers has gone. |
| failed | The send itself broke. failure_reason says how. |

### What happened to each person

GET /v1/broadcasts/:id/recipients is the per-recipient log, filterable by status, and it is where "did Ada get it" is answered. The stats it returns alongside carry rates as well as counts.

Everyone it bounced for:
```bash
curl "https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/recipients?status=bounced&limit=100" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "stats": {
    "recipients": 1879,
    "sent": 1842,
    "delivered": 1801,
    "bounced": 28,
    "complained": 2,
    "failed": 13,
    "skipped": 37,
    "unique_opens": 604,
    "unique_clicks": 121,
    "total_opens": 903,
    "total_clicks": 168,
    "unsubscribed": 9,
    "rates": { "delivered": 0.978, "bounced": 0.015, "complained": 0.001 }
  },
  "recipients": [
    {
      "id": "0b7e5a41-9c62-4d38-a015-3e8f6b204c79",
      "email": "grace@example.com",
      "status": "bounced",
      "reason": "smtp; 550 5.1.1 Recipient address rejected: User unknown",
      "message_id": "f3a91c07-4e28-4b6d-9c15-8d02a7e5b431",
      "sent_at": "2026-07-28T09:14:12.004Z",
      "opened_at": null,
      "clicked_at": null
    }
  ]
}
```

### File it under a topic

Set topic_id on anything that is not strictly transactional. It gives every copy a one-click List-Unsubscribe for that topic alone, it adds the footer line pointing at the preference centre, and it refuses to send to anybody who already opted out. Without it a recipient who wants to stop hearing from you has one available button, and it is the spam button. See /docs/topics.

- `GET /v1/broadcasts` Broadcasts, newest first. limit defaults to 50 and tops out at 100.
- `POST /v1/broadcasts` Create a draft. from is the one required field.
- `GET /v1/broadcasts/:id` One broadcast with its body, its stats, and its problems[].
- `PATCH /v1/broadcasts/:id` Edit a draft. from is not patchable, and a broadcast past draft answers 409.
- `DELETE /v1/broadcasts/:id` Cancel it.
- `POST /v1/broadcasts/:id/cancel` The same cancel, as a POST.
- `POST /v1/broadcasts/:id/send` { scheduled_at? }. Without it, it goes now.
- `POST /v1/broadcasts/:id/test` { to } sends it to up to 5 addresses of yours.
- `GET /v1/broadcasts/:id/preview` ?email= renders it without sending, and lists the merge fields.
- `POST /v1/broadcasts/preview` The same render for a body you have not saved. Nothing is written.
- `GET /v1/broadcasts/:id/recipients` ?status=&limit=&offset= over per-recipient results.

#### `broadcasts.create`

`{ from: string, audience_id?: string, segment_id?: string, topic_id?: string, subject?: string, name?: string, html?: string, text?: string, template_id?: string, template_version_id?: string, reply_to?: string, track_opens?: boolean, track_clicks?: boolean }`

Create a broadcast as a draft. The response carries ready and problems[], so you can tell whether it can send yet without trying.

| Parameter | Type | Required |
| --- | --- | --- |
| from | `string` | yes |
| audience_id | `string` | no |
| segment_id | `string` | no |
| topic_id | `string` | no |
| subject | `string` | no |
| name | `string` | no |
| html | `string` | no |
| text | `string` | no |
| template_id | `string` | no |
| template_version_id | `string` | no |
| reply_to | `string` | no |
| track_opens | `boolean` | no |
| track_clicks | `boolean` | no |

Returns: { id, status: "draft", ready, problems[] }

#### `broadcasts.send`

`{ id: string, scheduled_at?: string }`

Send a draft, or book it. Sending now answers with the recipient count and how many were skipped; booking answers with the time it will go.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| scheduled_at | `string` | no |

Returns: { id, status, queued, batches, skipped, recipients } or { id, status, scheduled_at }

#### `broadcasts.test`

`{ id: string, to: string | string[] }`

Send the broadcast to yourself first, rendered exactly as a recipient would get it. It does not change the draft status.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| to | `string | string[]` | yes |

Returns: { sent, skipped }

#### `broadcasts.preview`

`{ id: string, email?: string }`

The rendered subject and body without sending anything, plus merge_fields saying which fields the audience actually supplies and which are missing.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| email | `string` | no |

Returns: { subject, html, text, merge_fields[], audience_sample_size }

#### `broadcasts.previewContent`

`{ subject: string, html?: string, text?: string, from?: string, replyTo?: string, audienceId?: string, email?: string }`

The same render as broadcasts.preview, for a body you have not saved. Nothing is written and nothing is sent, so id comes back null.

| Parameter | Type | Required |
| --- | --- | --- |
| subject | `string` | yes |
| html | `string` | no |
| text | `string` | no |
| from | `string` | no |
| replyTo | `string` | no |
| audienceId | `string` | no |
| email | `string` | no |

Returns: { id: null, subject, html, text, headers, merge_fields[], audience_sample_size }

#### `broadcasts.recipients`

`{ id: string, status?: string, limit?: number, offset?: number }`

Per-recipient results for one broadcast, with the reason a skipped or failed row did not go.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| status | `string` | no |
| limit | `number` | no |
| offset | `number` | no |

Returns: { stats, recipients: Recipient[] }

#### `broadcasts.cancel`

`{ id: string }`

Call off a scheduled or sending broadcast. Messages already handed to the mail servers have gone.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { id, status: "cancelled" }

Open and click tracking are off unless you turn them on per broadcast, and both want a custom tracking domain so the rewritten links carry your name. See /docs/tracking-domain.

## Automations

https://emails.sh/docs/automations

An automation is a sequence of steps that runs for one contact: send this, wait three days, if they have not opened it send the other one, tag them either way. It is described by a YAML document, and that document is the source of truth. The canvas in the dashboard and the file are two views of the same graph, and either can be the one you edit.

The file is the point. A sequence written as a document is one an assistant can write, a reviewer can read in a diff, and a deploy pipeline can push, which is not true of anything you drag around a canvas.

### A complete automation

Steps fall through to the next one in file order unless a step says otherwise, so a linear sequence needs no wiring at all. This one is the whole language for most people.

automations/trial-nudges.yaml:
```text
name: Trial nudges
description: Three messages over the first week of a trial.
trigger: contact.subscribed
when:
  audience: Trials
reentry: once
enabled: true
steps:
  - id: welcome
    do: send_template
    with:
      template: trial-welcome
      topic: product-updates
  - id: wait_3d
    wait: 3 days
  - id: opened_it
    if:
      any:
        - steps.welcome.happened = true
    yes: tag_engaged
    no: nudge
  - id: nudge
    do: send_template
    with:
      template: trial-day-3
      topic: product-updates
    next: []
  - id: tag_engaged
    do: add_tag
    with:
      tag: engaged
    next: []
```

### The document

| Key | What it is |
| --- | --- |
| name | Required. What the automation is called. |
| description | Optional prose. |
| trigger | Required. One of the triggers below. |
| when | A mapping filtering the trigger. What it accepts depends on the trigger family. |
| reentry | once, re_enter, or always. Defaults to once, which is one live run per contact. |
| enabled | Defaults to true. |
| layout | auto or manual. auto means the document carries no coordinates and the canvas computes them. |
| at | Only under layout: manual. [x, y] on every step. |
| entry | Which step the trigger flows into. Defaults to the first one. |
| steps | Required, at least one. |

### Triggers

Seventeen of them, in four families. Everything except the last three starts a run because something happened to a contact.

| Trigger | When it fires |
| --- | --- |
| contact.subscribed | Somebody became subscribed on an audience. |
| contact.added | Somebody was added to an audience, whatever status they landed in. |
| contact.removed | A membership was removed. |
| contact.unsubscribed | Somebody unsubscribed. |
| tag.added | A tag was added to a contact. Swept every five minutes rather than instantly. |
| tag.removed | A tag was removed. |
| attribute.changed | A contact attribute changed value. |
| email.delivered | One of your messages was accepted by the receiving server. |
| email.opened | A tracking pixel loaded. A floor, not a count. |
| email.clicked | A tracked link was followed. |
| email.bounced | A message was refused. |
| email.complained | Somebody pressed the spam button. |
| email.received | Inbound mail arrived on a domain of yours. |
| broadcast.sent | A broadcast finished sending. |
| date.attribute | A date attribute on a contact came due. This is how renewals and birthdays work. |
| schedule.recurring | A clock, not an event. Runs on a schedule with no contact attached. |
| api.call | POST /v1/automations/:id/trigger started it. |

### The when: filter

For every event trigger, when takes any of audience, tag, attribute, topic, and template. Each is an exact match ignoring case, and a key you leave out matches everything. So a trigger with no when at all fires on every occurrence.

Filtering an event trigger:
```text
# Only when the tag "vip" is added, and only on the Customers audience.
name: VIP welcome
trigger: tag.added
when:
  tag: vip
  audience: Customers
steps:
  - id: greet
    do: send_template
    with:
      template: vip-welcome
    next: []
```

schedule.recurring and date.attribute take a different set, because neither is filtering an event.

| Trigger | when: keys |
| --- | --- |
| schedule.recurring | hourUtc (0 to 23, defaults to 9), weekday (0 to 6 with Sunday as 0), dayOfMonth. Give weekday for weekly, dayOfMonth for monthly, and neither for daily. |
| date.attribute | attribute (required, and it holds a YYYY-MM-DD date), offsetDays (defaults to 0, and a negative number fires before the date), hourUtc (defaults to 9), recurring (true for an anniversary that fires every year). |

A date-driven sequence with no cron of your own:
```text
# Seven days before renewal_date, at 09:00 UTC, every year.
name: Renewal reminder
description: Fires a week before the date on the contact.
trigger: date.attribute
when:
  attribute: renewal_date
  offsetDays: -7
  hourUtc: 9
  recurring: true
reentry: always
steps:
  - id: warn
    do: send_template
    with:
      template: renewal-reminder
      variables:
        renews_on: "{{ attributes.renewal_date }}"
    next: []
```

### Steps

Every step has an id, unique within the document and never the word trigger, and exactly one of if, do, or wait. Non-branching steps wire with next, which takes an id, a list of ids, or an empty list to end the run. An if step wires with yes and no instead. Omit next and the step falls through to the next one in the file.

### The eleven actions

Arguments go under with. An argument name that is not on this list is refused when the document is saved rather than ignored at run time. Notice what is not here: no arbitrary code, no database access, no way to delete anything. The blast radius of a document somebody pasted in is this table.

| do: | Arguments, required ones first |
| --- | --- |
| send_template | template (required, a slug). topic, from, replyTo, variables. |
| send_audience | audience (required), template (required). topic, from, replyTo. Sends to every mailable member. |
| add_tag | tag (required). |
| remove_tag | tag (required). |
| add_to_audience | audience (required). status. |
| remove_from_audience | audience (required). |
| set_attribute | name (required). value. |
| unsubscribe | audience. With none, the audience that enrolled the run. |
| suppress | email, reason. With no email, the contact this run is about. |
| call_webhook | url (required). event, data. POSTs a signed automation.step event. |
| notify_team | to (required), body (required). subject. The address must belong to this workspace, and anything else is refused. |

Three of them put mail in front of a person: send_template, send_audience, and notify_team. Those are the ones counted against the per-run send cap of 10.

### The three waits

| Form | What it does |
| --- | --- |
| wait: 3 days | A duration. minutes, mins, hours, hrs, or days, clamped between one minute and 365 days. |
| wait: { until: "09:00" } | The next occurrence of a time of day, in UTC. { until: monday 09:00 } names a weekday too. |
| wait: { for: email.opened, timeout: 3 days } | Wait for an event about this contact, or give up after the timeout. The timeout defaults to 7 days. |

A wait for an event resumes early the moment it happens. Whether it happened is readable afterwards as steps.<id>.happened, which is how a branch tells "opened it" apart from "we gave up waiting".

### Conditions

An if step takes all or any, with between 1 and 10 rules, and each rule is one line reading path op value. One line rather than a three-key mapping because ten mappings in a diff are unreadable and ten lines are not.

| Operator | What it does |
| --- | --- |
| = | Equal. Strings compare without case. |
| != | Not equal. |
| > | Greater than, numerically. |
| < | Less than, numerically. |
| >= | Greater than or equal. |
| <= | Less than or equal. |
| contains | Substring on a string, membership on a list. tags contains vip is the common one. |
| not_contains | The negation of contains. |
| is_set | The path resolves to something. Takes no value. |
| is_not_set | It does not. Takes no value. |

Paths read the run context: attributes.plan, tags, contact.email, trigger.anything you passed in, and steps.<id>.happened. A quoted value stays a string, so count = "3" is the string and count = 3 is the number.

### A branching example

This one waits for an open, branches on whether it arrived, and reads a value the API trigger passed in. It is a complete document and it will save as it stands.

A document with a branch, a wait, and trigger data:
```text
name: Onboarding checkpoint
description: Fired by our backend when a workspace finishes setup.
trigger: api.call
reentry: re_enter
enabled: true
steps:
  # trigger.plan comes from the "data" object on the trigger call.
  - id: is_paid
    if:
      any:
        - trigger.plan = pro
        - trigger.plan = enterprise
    yes: send_paid
    no: send_free

  - id: send_paid
    do: send_template
    with:
      template: onboarding-paid
      topic: product-updates
      variables:
        workspace: "{{ trigger.workspace_name }}"
    next: await_open

  - id: send_free
    do: send_template
    with:
      template: onboarding-free
      topic: product-updates
    next: await_open

  - id: await_open
    wait:
      for: email.opened
      timeout: 3 days
    next: check_open

  - id: check_open
    if:
      all:
        - steps.await_open.happened = true
    yes: tag_engaged
    no: tell_us

  - id: tag_engaged
    do: add_tag
    with:
      tag: onboarding-engaged
    next: []

  - id: tell_us
    do: notify_team
    with:
      to: growth@acme.com
      subject: Onboarding stalled
      body: "{{ contact.email }} did not open onboarding in three days."
    next: []
```

### Interpolation

Any argument value takes {{ dot.path }} against the run context. A string that is exactly one variable and nothing else keeps the value's type rather than becoming text, so a number stays a number when it is passed straight through.

### Limits

| Limit | Value |
| --- | --- |
| Nodes in one document | 80. Past that it is a program rather than a sequence. |
| Nodes executed in one run | 200, counting every resume. This is what bounds a fan-out. |
| Emails one run may send | 10. A run that would exceed it stops and reports rather than sending the eleventh. |
| Runs started per hour | 500 per automation, across every trigger path. This is the loop guard. |
| Live runs per contact | 1 under reentry once, which is the default. |
| Argument size | 4000 characters of arguments and 16000 characters of payload. |
| Wait length | One minute to 365 days. |

### Pull and push

Two routes make the document a file you can keep in your repository. GET the .yaml form and you get the raw bytes with the version in a response header; PUT it back and you have written a new version. A round trip through those two preserves your comments and your key order exactly, so the file in git and the file on the server stay diffable.

The pull and push pair:
```bash
ID=91c4e0a7-6f28-4b53-8d10-2a7e5cb93f61

# Pull, keeping the version header so you can tell whether it moved
curl -sS -D headers.txt \
  "https://emails.sh/v1/automations/$ID.yaml" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -o automations/trial-nudges.yaml

grep -i '^x-emailssh-automation-version' headers.txt

# Push it back after editing
curl -sS -X PUT "https://emails.sh/v1/automations/$ID.yaml" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/yaml" \
  --data-binary @automations/trial-nudges.yaml
```

x-emailssh-automation-version carries the version number the document is at. Record it when you pull, and compare it before you push, and a CI job knows whether somebody edited the automation in the dashboard while the branch was open.

Every save writes a version. GET /v1/automations/:id/versions lists the last 50 with their YAML, and POSTing { version_id } to that route restores one as a new version rather than rewinding history.

### Triggering one from your code

An automation whose trigger is api.call has a trigger_url on its GET response and starts a run when you post to it. idempotency_key is required rather than optional, because the caller is usually a webhook handler and a redelivered webhook must not enrol somebody twice.

POST /v1/automations/:id/trigger:
```bash
curl -X POST https://emails.sh/v1/automations/91c4e0a7-6f28-4b53-8d10-2a7e5cb93f61/trigger \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "idempotency_key": "workspace-8812-setup-complete",
    "data": { "plan": "pro", "workspace_name": "Acme" }
  }'
```

| Refusal | What it means |
| --- | --- |
| 409 duplicate | That idempotency_key already started a run. Nothing happened, which is the point. |
| 422 automation_disabled | enabled is false. |
| 422 already_enrolled | Under reentry once, this contact already has a run. |
| 422 run_in_flight | A run for this contact is executing right now. |
| 422 concurrency_cap | Too many runs at once. |
| 422 hourly_cap | Past 500 runs in the last hour. |

### Reading a run

GET /v1/automations/:id/runs lists them, and GET on one run returns every step it executed, in order, with what each one did. That list is the debugger: a sequence that "did not send" has a step in it whose status says why.

GET /v1/automations/:id/runs/:runId:
```json
{
  "id": "3b8f7d21-5c04-4e96-a71d-8f26c0b4e953",
  "email": "ada@example.com",
  "contact_id": "b4c0e21f-7d69-4a55-8e13-2f9a6c081d77",
  "status": "waiting",
  "version_id": "c05a9e13-4b72-4f80-9d36-1a7c8e5b204f",
  "started_at": "2026-07-28T09:14:01.882Z",
  "finished_at": null,
  "resume_at": "2026-07-31T09:14:01.882Z",
  "steps_executed": 3,
  "emails_sent": 1,
  "error": null,
  "steps": [
    { "nodeId": "trigger", "kind": "trigger", "status": "ok" },
    { "nodeId": "is_paid", "kind": "condition", "status": "branch_true" },
    { "nodeId": "send_paid", "kind": "action", "tool": "send_template", "status": "ok" },
    { "nodeId": "await_open", "kind": "wait", "status": "waiting" }
  ]
}
```

- **ok**: The step did what it said.
- **skipped**: It was reached and had nothing to do.
- **error**: It failed. The error is on the step.
- **branch_true and branch_false**: Which way an if step went.
- **waiting**: A wait that has not resumed. resume_at on the run says when it will.
- **timed_out**: A wait for an event that never arrived. happened is false on it.

A run that is waiting can be cancelled with DELETE on it. A run in any other state cannot, because there is nothing left to stop.

- `GET /v1/automations` Automations, with their trigger, version, and last error.
- `POST /v1/automations` Raw YAML, or { yaml } as JSON.
- `GET /v1/automations/:id` One automation, with its graph, its YAML, and its trigger URL if it has one.
- `PATCH /v1/automations/:id` { enabled?, yaml? }
- `DELETE /v1/automations/:id` Remove it, and cancel every waiting run.
- `GET /v1/automations/:id.yaml` The document, with the version in x-emailssh-automation-version.
- `PUT /v1/automations/:id.yaml` Replace the document with the raw YAML body.
- `GET /v1/automations/:id/versions` The last 50 versions, each with its YAML.
- `POST /v1/automations/:id/versions` { version_id } restores one as a new version.
- `POST /v1/automations/:id/trigger` { email | contact_id, idempotency_key, data? } starts a run.
- `GET /v1/automations/:id/runs` ?status=&limit= over runs.
- `GET /v1/automations/:id/runs/:runId` One run and every step it executed.
- `DELETE /v1/automations/:id/runs/:runId` Cancel a run that is waiting.

#### `automations.create`

`{ yaml: string }`

Create an automation from a YAML document. It is validated whole: a refusal names the field, says what to write instead, and carries the line number.

| Parameter | Type | Required |
| --- | --- | --- |
| yaml | `string` | yes |

Returns: { id, name, slug, trigger, enabled, version, yaml }

#### `automations.pull`

`{ id: string }`

GET /v1/automations/:id.yaml. The raw document, with the current version in the x-emailssh-automation-version response header. Comments and key order survive the round trip.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: text/yaml

#### `automations.push`

`{ id: string, yaml: string }`

PUT /v1/automations/:id.yaml. Replaces the document and writes a new version.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| yaml | `string` | yes |

Returns: text/yaml

#### `automations.trigger`

`{ id: string, email?: string, contact_id?: string, idempotency_key: string, data?: object }`

Start a run of an automation whose trigger is api.call.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| email | `string` | no |
| contact_id | `string` | no |
| idempotency_key | `string` | yes |
| data | `object` | no |

Returns: { run_id, status }

#### `automations.runs`

`{ id: string, status?: string, limit?: number }`

Runs of one automation, with how many steps executed, how many emails went, and the error if it stopped.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| status | `string` | no |
| limit | `number` | no |

Returns: { runs: Run[] }

Every refusal from the YAML parser carries a code, a message naming the field, a sentence saying what to write instead, and a line number. That is for the assistant writing the document: it reads the error and fixes itself, which is the whole reason this surface is a file.

## The account as code

https://emails.sh/docs/account-as-code

An automation round-trips losslessly to YAML, so a lifecycle sequence lives in your git repository, gets reviewed in a pull request, and deploys from CI. This chapter is the rest of that idea: a plan command that shows what a deploy would do, a GitHub Action that runs it on a pull request and comments the diff, and a Terraform provider for the parts of the account that are not automations.

Why it is built this way: an assistant writes YAML and Terraform far more reliably than it clicks through a dashboard, and a change that arrives as a diff is one a person can actually approve before it mails anybody. A worked repository is at https://github.com/emailssh/emails.sh/tree/main/example-account-as-code.

### What belongs in which

| Thing | Managed by |
| --- | --- |
| Automations | YAML files plus the Action, or the Terraform provider. One or the other, never both on the same document. |
| Sending domains and their DNS records | Terraform. The records come out as an attribute you feed to your own DNS provider. |
| API keys | Terraform. |
| Webhook endpoints | Terraform. |
| Topics and audiences | Terraform. |
| Templates | Neither. They have a draft, version, and publish lifecycle that a create-or-replace resource would flatten. |
| Contacts, suppressions, broadcasts | Neither. People are not configuration, a suppression is a record of what happened, and a broadcast is an event. |

### emails automations plan

The shape terraform plan has, for the same reason: what a reviewer approves is the diff, not the intention. It reads every document in a directory, reads the workspace, prints the difference, and writes nothing. Safe to run against production from a laptop.

Reads the directory and the workspace, writes nothing:
```bash
npx @emails.sh/cli automations plan automations
```

What a plan looks like:
```text
automations against this workspace

+ create                renewal-reminder            automations/renewal-reminder.yaml
~ update                trial-nudges                automations/trial-nudges.yaml (remote v4)
        - id: wait_3d
    -     wait: 3 days
    +     wait: 5 days

  no change             onboarding-checkpoint       automations/onboarding-checkpoint.yaml (remote v2)
? not in this directory  legacy-drip                exists on the workspace at v7, no document here

Plan: 1 to create, 1 to update, 1 unchanged.
```

An automation is matched to a document by slug, and the slug is derived from the name key inside the file rather than from the filename. That is what the API itself does on every save, so matching any other way would drift. A file whose name does not agree with its filename is called out in the plan, because the person reading the diff is looking at a filename that is not the name of the thing being changed.

Running apply twice does nothing the second time. The API stores the exact bytes it was sent rather than a re-serialisation of them, so byte equality is an exact test for "already deployed" and a document that matches is not written again. No version is created, and your history stays a record of real edits.

| Exit code | What it means |
| --- | --- |
| 0 | Every document was read. There may or may not be changes. |
| 1 | A document could not be read. Nothing would be applied. |
| 2 | Under --detailed-exitcode only: read cleanly, changes pending. |

Add --format markdown for the pull request comment form: a summary line, a table, and each diff in a collapsed block. That is what the Action posts.

### emails automations apply

The plan, then the writes it described. It creates what is missing and updates what differs. It never deletes: an automation on the workspace with no document in the directory is reported and left alone, because a directory of files is not a statement that nothing else may exist, and somebody’s first canvas experiment should survive a CI run.

It is all or nothing as far as this API allows. Every document is parsed before anything is sent, the remote bytes of everything about to change are captured first, and a refusal partway through puts back what was already written. A half-deployed lifecycle sequence is worse than an undeployed one, because half of it will still mail people. The rollback is itself a write, so a rolled-back automation gains a version rather than losing one.

### The GitHub Action

On a pull request it plans and comments. On a push it applies. One workflow, because the action reads the event and decides, and splitting them means two files to keep in step.

.github/workflows/automations.yml:
```text
name: emails.sh automations

on:
  pull_request:
    paths: ['automations/**']
  push:
    branches: [main]
    paths: ['automations/**']

permissions:
  contents: read
  pull-requests: write

jobs:
  automations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: emailssh/emails.sh/.github/actions/automations@v1
        with:
          api-key: ${{ secrets.EMAILSSH_API_KEY }}
          directory: automations
```

The comment is one comment per pull request, edited in place on every push, so a branch with twenty commits does not produce twenty notifications. The plan also goes to the job summary and the job log.

| Input | What it does |
| --- | --- |
| api-key | Required. A key with the workspace scope is enough, and is what you want: the action never sends mail. |
| directory | Where the documents are. Defaults to automations. |
| mode | plan, apply, or auto. auto is the default and plans on a pull request, applies on anything else. |
| comment | Whether to write the pull request comment. Defaults to true. |
| fail-on-changes | Fails the job when a plan has pending changes. Use it on a branch that is supposed to be deployed already, so drift is caught rather than reported. |
| cli-version | The version of @emails.sh/cli to run. Pin it once you are past the first week. |
| base-url | For a staging deployment. |

A document that cannot be read fails the job with the file and the line number, and the comment carries the same. Nothing is applied, including the documents beside it that were fine. That is the guarantee: a directory deploys whole or not at all.

What plan checks locally is the top level of the document: a tab in the indentation, a key written twice, a missing or empty name, a missing trigger, a steps key with nothing under it. Everything past that is checked by the API, which owns those rules, and it answers with a code, a line number, and a sentence saying what to write instead. So a document that is well-formed at the top and wrong further down is refused at apply time rather than at plan time, and apply rolls back rather than half-deploying. The alternative was a second validator in the CLI that would drift from the real one, and a plan that passes against an apply that fails is worse than no plan.

### The Terraform provider

Provider configuration:
```text
terraform {
  required_providers {
    emailssh = {
      source  = "emailssh/emailssh"
      version = "~> 0.1"
    }
  }
}

# Reads EMAILSSH_API_KEY from the environment, so the key never reaches a
# .tfvars file. Create one at https://emails.sh/dashboard/api-keys.
provider "emailssh" {}
```

| Resource | What it manages |
| --- | --- |
| emailssh_domain | A sending domain, and its DNS records as an output. Every attribute is ForceNew: the API cannot rename a domain. |
| emailssh_api_key | A key. The secret is readable once, at creation, so it lives in Terraform state and nowhere else. |
| emailssh_webhook | A delivery endpoint, with full in-place updates. The signing secret is returned once. |
| emailssh_automation | One document, from yaml_file or an inline yaml string. |
| emailssh_topic | A subscription group. key is ForceNew, because the API refuses to change one. |
| emailssh_audience | A list. |

### DNS records as an output

This is the part worth the whole provider. Verifying a domain is normally a screen you copy three DKIM records out of by hand, once per environment. Here the records are an attribute of the domain and an input to your own DNS provider, in one apply.

The records, created wherever your DNS lives:
```text
resource "emailssh_domain" "acme" {
  domain = "acme.com"
}

resource "cloudflare_dns_record" "emailssh" {
  for_each = {
    for r in emailssh_domain.acme.dns_records : "${r.type}/${r.name}" => r
    if !r.optional
  }

  zone_id  = var.cloudflare_zone_id
  name     = each.value.name
  type     = each.value.type
  content  = each.value.content
  priority = each.value.type == "MX" ? each.value.priority : null
  ttl      = 300
  proxied  = false
}
```

| Field on a record | What it is |
| --- | --- |
| type | MX, TXT, or CNAME. |
| name | The fully qualified record name, for example _emailssh.acme.com. |
| value | The record exactly as the API states it. For MX this includes the priority inline. |
| content | value with the MX priority removed, for providers that take priority as its own argument. Identical to value for every other type. |
| priority | The MX priority, or 0. |
| purpose | DKIM, ownership, SPF, DMARC, or inbound. |
| note | Guidance, for example how to merge with an SPF record you already publish. |
| optional | True when the record is not needed for verification. The inbound MX on an apex domain is optional because publishing it takes delivery of mail for the whole domain. |

### The caveats, in full

- **Terraform does not create your DNS records**: It creates them wherever you point it, which is a different thing. emails.sh does not control your zone and never will. If your registrar has no Terraform provider, read terraform output dns_records and paste.
- **DKIM records arrive a moment late**: The three CNAMEs are issued when the domain is registered upstream, not when the row is created, so the first apply can return a list without them and dkim_records_pending set to true. Apply again. Until they are published, mail from the domain is unsigned and will be filtered.
- **Drift reverts on the next apply**: terraform plan reads the workspace, so a dashboard edit shows as a diff and the next apply overwrites it. For an automation that means your file is pushed over whatever was drawn on the canvas, and the canvas edit becomes a version in the history rather than being lost: emails automations pull recovers it.
- **A revoked key comes back as a different key**: A key secret is readable exactly once. If one is revoked outside Terraform, the plan shows it as gone and the apply mints a new one with a new secret, which every service holding the old one then needs.
- **Secrets live in state**: API keys and webhook signing secrets are returned once and kept in the state file, because there is nowhere else for them to be. Use a remote backend that encrypts state.
- **Two ways to deploy an automation, and they do not mix**: The Action and emailssh_automation both own a document. Managing one document with both means every apply reverts the other. Pick one per automation.
- **Nothing here deletes an automation**: apply creates and updates. Removing a file does not remove the automation, it reports it as unmanaged. Delete deliberately, with emails automations or the dashboard.

A plan is a read of live state, not a lock on it. Two pipelines applying the same directory at once will race, and the second one wins. Use one concurrency group per workspace, which the example workflow does.

- **The YAML format** (/docs/automations): Every trigger, action, wait, and condition, and what the parser refuses.
- **Domains** (/docs/domains): What each DNS record is for and how verification finishes.
- **API keys** (/docs/api-keys): Scopes, expiry, and what a key created by a program may do.

## Hosted templates

https://emails.sh/docs/templates

A template is a subject and a body kept on your workspace, with {{ variables }} in them. Your code sends the name and the values, so changing the wording of a receipt is one API call rather than a deploy.

Substitution is {{ name }} and nothing else. There are no conditionals, no loops, and no expressions, because a stored template must never become code we run on your behalf. Anything that needs a decision is a decision your code makes before it sends.

### Create a template

POST /v1/templates:
```bash
curl -X POST https://emails.sh/v1/templates \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome",
    "slug": "welcome",
    "subject": "Welcome, {{ name }}",
    "html": "<p>Hello {{ name }}, your plan is {{ plan }}.</p>",
    "publish": true
  }'
```

The slug is what your code names. It is stable, so you can rename the template in the dashboard without touching a line of your application. publish: true makes that first version live immediately; leave it out and the template exists with nothing published, and a send that names it is refused with 422 template_not_published.

### Versions and publishing

Every edit writes a new version, and a version is never changed after it is written. That is what makes a message sent last March explainable today: the delivery log records the exact version id that produced it, and that row still says what it said.

Saving never publishes. Live sends render the published version and nothing else, so you can edit a password reset at leisure and it reaches nobody until you publish it.

Save, then publish:
```bash
# Save a new draft version. These two paths take the template id
# rather than the slug, which POST /v1/templates returned when you
# created it.
curl -X POST https://emails.sh/v1/templates/3c1a7f92-5b8e-4d61-9a03-7e4c2b8d1f60/versions \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subject": "Welcome aboard, {{ name }}", "html": "<p>Hello {{ name }}.</p>"}'

# Point live sends at it
curl -X POST https://emails.sh/v1/templates/3c1a7f92-5b8e-4d61-9a03-7e4c2b8d1f60/publish \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"version_id": "9f2c1b4e-3d5a-4c7e-8b21-0d6f5a3c1e88"}'
```

### Send one

POST /v1/emails with a template:
```bash
curl -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.com>",
    "to": ["ada@example.com"],
    "template": {
      "id": "welcome",
      "variables": { "name": "Ada", "plan": "Pro" }
    }
  }'
```

The template supplies the subject, the HTML, and the text alternative. Anything you also set on the request wins, so passing subject alongside a template overrides just the subject for that one send. template.id takes the slug or the template id, whichever you have to hand.

### A missing variable fails the send

If the published version uses a variable and the request supplies no value for it, the send is refused with 422 template_variables_missing, the response names the variables, and nothing is sent. A receipt that reads "Hello ," is worse than a send that failed loudly, so there is no mode in which a blank is substituted.

422 from POST /v1/emails:
```json
{
  "error": "template_variables_missing",
  "missing": ["plan"],
  "message": "The published version of template welcome uses plan and the request supplied no value for it. Pass every one in `template.variables`, or give the variable a default on the template. Nothing was sent."
}
```

Give a variable a default on the template when a blank really is acceptable. Previewing is the one place the strict rule relaxes: the dashboard preview and the preview endpoint fill anything you have not supplied with a sample value, so you can see the layout before you have wired the data up. Only previews do that. Sends never do.

Every message records the template id and the version id it rendered from, and GET /v1/emails/:id shows them. That is how you answer "which wording did this customer actually read" after five edits.

## Topics and the preference centre

https://emails.sh/docs/topics

A topic is a named category of mail: product updates, a monthly digest, password resets. File a send under one and the recipient can turn that category off on its own, instead of choosing between hearing everything and hearing nothing.

This is the difference between an unsubscribe that costs you one newsletter and an unsubscribe that costs you the ability to send somebody their receipts.

### Create a topic

POST /v1/topics:
```bash
curl -X POST https://emails.sh/v1/topics \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "product-updates",
    "name": "Product updates",
    "default_opt_in": true
  }'
```

The key is what a send names and what sits inside opt-out links that are already sitting in mailboxes, so it cannot be changed afterwards. The name is what a recipient reads on the preference page and is free to change.

| Field | What it means |
| --- | --- |
| default_opt_in | What silence means. true sends to anybody who has not said no. false sends only to people who have said yes, and is the only correct setting for anything a regulator would call marketing. |
| required | Cannot be switched off. Password resets and receipts are not marketing, and the preference page shows them without a switch. |

### Send under a topic

POST /v1/emails with a topic:
```bash
curl -X POST https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.com>",
    "to": ["ada@example.com"],
    "subject": "What changed in July",
    "html": "<p>Three new things.</p>",
    "topic": "product-updates"
  }'
```

Three things happen because that field is there. The recipient is checked before anything is sent, and a send to somebody who opted out is refused with 422 topic_opt_out rather than delivered. The message gets a List-Unsubscribe header pointing at a one-click opt-out for that topic alone. And a footer line is added with that link beside a link to the full preference page.

The unsubscribe link is never rewritten by click tracking. A one-click unsubscribe behind a redirect breaks RFC 8058, which is the rule Gmail and Yahoo cite in their bulk sender requirements.

### How the answer is decided

One rule, checked in a fixed order, for every send and every recipient: a suppression, then required, then what the person said, then the topic default.

- **A suppression beats everything**: A hard bounce, a spam complaint, or a workspace-wide unsubscribe stops the send whatever anybody opted into, including a required topic. That is 422 recipient_suppressed.
- **A required topic cannot be opted out of**: Somebody with an opt-out row against a required topic still receives it. There is no consent state in which withholding a password reset is a service.
- **A stated preference is obeyed**: Whichever way they said it, that is the answer. Silence falls through to the topic default.

### The preference centre

Every topic-filed message carries a link to a hosted page where the recipient sees every live topic and answers all of them at once. It is signed per address, so a link issued to one person cannot be replayed for another, and required topics appear without a switch.

Preferences are keyed by email address rather than by contact, so somebody who has never been in an audience still gets a working opt-out. Read and write them from your own code as well.

Record an opt-out from your own settings page:
```bash
curl -X POST https://emails.sh/v1/topics/preferences \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "topic": "product-updates",
    "subscribed": false
  }'
```

Leave topic off a send that is genuinely transactional and it goes out governed by the suppression list alone, as before. Adding a topic to a receipt only makes sense if you mark that topic required.

## Analytics

https://emails.sh/docs/analytics

One endpoint answers "how is our mail doing". It returns totals over a window, a series broken into days, weeks, or months, and an optional breakdown by domain, tag, mail class, or template.

GET /v1/analytics:
```bash
curl "https://emails.sh/v1/analytics?from=2026-07-01&to=2026-07-31&group_by=day&breakdown=domain" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "range": { "from": "2026-07-01", "to": "2026-07-31", "group_by": "day" },
  "totals": {
    "sent": 128400,
    "queued": 12,
    "rejected": 318,
    "in_flight": 40,
    "delivered": 126902,
    "bounced": 1180,
    "complained": 46,
    "delivered_rate": 0.9883,
    "bounce_rate": 0.0092,
    "complaint_rate": 0.0004,
    "clicks_tracked": 41200,
    "clicked": 5104,
    "clicks": 7318,
    "click_rate": 0.1239,
    "opens_tracked": 41200,
    "opened": 18422,
    "opens": 26104
  },
  "series": [
    { "period": "2026-07-01", "sent": 4102, "delivered": 4061, "bounced": 33, "complained": 1, "delivered_rate": 0.99, "bounce_rate": 0.008, "complaint_rate": 0.0002, "clicks_tracked": 1300, "clicked": 160, "clicks": 214, "click_rate": 0.123, "opens_tracked": 1300, "opened": 590, "opens": 812, "queued": 0, "rejected": 8, "in_flight": 0 }
  ],
  "breakdown": {
    "by": "domain",
    "tag_key": null,
    "rows": [
      { "key": "gmail.com", "label": "gmail.com", "sent": 61200, "delivered": 60800, "bounced": 310, "complained": 22, "delivered_rate": 0.9935, "bounce_rate": 0.0051, "complaint_rate": 0.0004, "clicks_tracked": 20100, "clicked": 2600, "clicks": 3700, "click_rate": 0.1294, "opens_tracked": 20100, "opened": 9400, "opens": 13200, "queued": 0, "rejected": 140, "in_flight": 20 },
      { "key": null, "label": "Other (312)", "sent": 8200, "delivered": 8080, "bounced": 96, "complained": 3, "delivered_rate": 0.9854, "bounce_rate": 0.0117, "complaint_rate": 0.0004, "clicks_tracked": 2600, "clicked": 300, "clicks": 402, "click_rate": 0.1154, "opens_tracked": 2600, "opened": 1100, "opens": 1520, "queued": 0, "rejected": 20, "in_flight": 0 }
    ]
  },
  "notes": {
    "opens": "Open tracking undercounts. Image blocking and proxy prefetching both distort it.",
    "tracking": "Only messages sent with tracking on are counted in opens_tracked and clicks_tracked.",
    "sources": "Counts come from delivery events, not from the send call."
  }
}
```

### Every metric

The same object appears in totals, in every series entry, and in every breakdown row, so a chart can read them the same way wherever it found them.

| Field | What it counts |
| --- | --- |
| sent | Messages handed to a receiving mail server. |
| queued | Accepted and not yet sent at the moment you asked. |
| rejected | Refused before sending: suppression, topic opt-out, an unverified domain. |
| in_flight | Sent, with no delivery or bounce event yet. |
| delivered | The receiving server confirmed it took the message. |
| bounced | Refused by the receiving server. |
| complained | Reported as spam through a feedback loop. |
| delivered_rate | delivered over sent. |
| bounce_rate | bounced over sent. The number that decides whether a workspace gets paused. |
| complaint_rate | complained over sent. Keep it under 0.001. |
| clicks_tracked | Messages sent with click tracking on. The denominator for click_rate. |
| clicked | Distinct messages that had at least one click. |
| clicks | Total clicks, including repeats by the same person. |
| click_rate | clicked over clicks_tracked. |
| opens_tracked | Messages sent with open tracking on. |
| opened | Distinct messages with at least one recorded open. |
| opens | Total recorded opens, including repeats. |

### There is no open_rate

Deliberately. Every other number here would let you compute one, and we do not publish it, because an open rate is not a measurement of anything stable.

An open is a one-pixel image loading. Every mail client that blocks images by default records no open from somebody who read the whole message. Apple Mail Privacy Protection and several corporate gateways go further and fetch the pixel on delivery whether or not anybody looked, so the same number is inflated for one part of your list and deflated for another. Dividing two distortions gives a rate that moves when Apple ships an update and not when your writing changes.

opened and opens are here because a floor is still information: an open that was recorded did probably happen. Use clicks for anything you intend to act on, and read the deliverability numbers, which are measured at the receiving server and are not guesses.

### Rates are null, not zero

When the denominator is 0, a rate comes back as null rather than 0. A day on which you sent nothing has a bounce_rate of null, because "no bounces out of no sends" is not a zero percent bounce rate, and a chart that draws it as one is drawing a line that says your deliverability was perfect on a day you were not sending.

Handle the null:
```ts
// Every rate is number | null. Render the gap rather than a zero.
type Point = { period: string; bounce_rate: number | null };

export function formatRate(point: Point): string {
  if (point.bounce_rate === null) return '\u2014';
  return `${(point.bounce_rate * 100).toFixed(2)}%`;
}
```

### Filters and breakdowns

| Parameter | What it takes |
| --- | --- |
| from, to | YYYY-MM-DD in UTC, and to is inclusive. Defaults to the last 30 days, and the window may be at most 400 days. |
| group_by | day, week, or month. Defaults to day. |
| breakdown | domain, tag, mail_class, or template. |
| tag_key | Required when breakdown is tag: it names which tag key to split on. |
| mail_class | transactional or marketing, as a filter. |
| domain | Filter to one recipient domain. |
| template_id | Filter to one template. |
| tag | Filter to one tag, written key:value. |

A breakdown returns the top 20 by sent, and everything below that is folded into one row whose key is null and whose label reads "Other (N)" with the number of things folded in. So the rows always add up to the totals, which they would not if the tail were dropped.

GET /v1/analytics/tags answers which tag keys are worth splitting on, with how much mail each one carried, up to 25 of them. Call it to populate a picker instead of asking somebody to remember what they tagged things with.

Find a tag key, then split by it:
```bash
# Which tag keys carried mail in the last 90 days
curl "https://emails.sh/v1/analytics/tags?days=90" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Then break the last month down by one of them
curl "https://emails.sh/v1/analytics?breakdown=tag&tag_key=campaign&group_by=week" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

| Refusal | What it means |
| --- | --- |
| 422 invalid_range | from or to is not a date, or from is after to. |
| 422 range_too_long | The window is more than 400 days. |
| 422 invalid_group_by | group_by is not day, week, or month. |
| 422 invalid_breakdown | breakdown is not one of the four. |
| 422 tag_key_required | breakdown=tag without tag_key. |
| 422 invalid_mail_class | mail_class is not transactional or marketing. |
| 422 invalid_tag_filter | tag is not written key:value. |

- `GET /v1/analytics` ?from=&to=&group_by=&breakdown=&mail_class=&domain=&template_id=&tag=
- `GET /v1/analytics/tags` ?days= returns the tag keys worth breaking down by.

#### `analytics.get`

`{ from?: string, to?: string, group_by?: "day" | "week" | "month", breakdown?: "domain" | "tag" | "mail_class" | "template", tag_key?: string, mail_class?: "transactional" | "marketing", domain?: string, template_id?: string, tag?: string }`

Sends, deliveries, bounces, complaints, clicks, and opens over a window, as totals and as a series.

| Parameter | Type | Required |
| --- | --- | --- |
| from | `string` | no |
| to | `string` | no |
| group_by | `"day" | "week" | "month"` | no |
| breakdown | `"domain" | "tag" | "mail_class" | "template"` | no |
| tag_key | `string` | no |
| mail_class | `"transactional" | "marketing"` | no |
| domain | `string` | no |
| template_id | `string` | no |
| tag | `string` | no |

Returns: { range, totals, series[], breakdown, notes }

#### `analytics.tags`

`{ days?: number }`

Which tag keys are worth breaking down by, with how much mail each carried. Up to 25.

| Parameter | Type | Required |
| --- | --- | --- |
| days | `number` | no |

Returns: { tag_keys: [{ key, sent }] }

Counts come from delivery events rather than from the send call, so a message sent a minute ago sits in in_flight until its receiving server answers. Numbers for the current day keep moving for a few minutes after you read them.

## Suppression list

https://emails.sh/docs/suppressions

The suppression list is the set of addresses this workspace will not send to. A send to one is refused with 422 recipient_suppressed rather than attempted, and an import writes such an address as cleaned rather than subscribed. It is the single most load-bearing piece of deliverability machinery here, and it works by saying no.

Addresses arrive on it on their own. A hard bounce, a spam complaint, and an unsubscribe all add one without you doing anything, which is exactly what you want: continuing to mail an address that bounced is the fastest way to lose a sending reputation, and the fastest way to do that by accident is to have to remember to stop.

### Read it

GET /v1/suppressions:
```bash
# Everything, newest first
curl "https://emails.sh/v1/suppressions?limit=200" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Just the complaints
curl "https://emails.sh/v1/suppressions?reason=complaint" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Ask about one address
curl "https://emails.sh/v1/suppressions?email=ada@example.com" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "suppressions": [
    {
      "id": "a6c40b98-7f21-4d53-9e08-3b15c7a2f640",
      "email": "grace@example.com",
      "reason": "bounce",
      "reason_detail": "smtp; 550 5.1.1 Recipient address rejected: User unknown",
      "is_global": false,
      "created_at": "2026-07-02T14:31:09.220Z"
    },
    {
      "id": "e19d7350-24ba-4c86-b0f7-5a83e6c14297",
      "email": "spamtrap@example.net",
      "reason": "complaint",
      "reason_detail": "feedback loop",
      "is_global": true,
      "created_at": "2026-05-18T07:02:44.006Z"
    }
  ],
  "next_cursor": "2026-05-18T07:02:44.006Z",
  "total": 1180
}
```

Pagination is a cursor: take next_cursor from a response and pass it back as before. It stops when next_cursor is null. limit defaults to 50 and takes anything from 1 to 200.

| reason | How it got there |
| --- | --- |
| bounce | The receiving server permanently refused it. reason_detail carries the remote server's own words. |
| complaint | Somebody pressed the spam button and the receiver told us through a feedback loop. |
| unsub | Somebody unsubscribed at the workspace level rather than from one topic. |
| manual | You added it, or support did. |

### The global rows

A row with is_global set is ours rather than yours: known spam traps, addresses that complain across every workspace, and the handful of things that are never a good idea to mail. They appear in your list so nothing is invisible, and DELETE on one answers 404. You cannot clear them and neither can support.

### Add one yourself

Somebody who asks you to stop by replying to the email, rather than by clicking the link, has unsubscribed just as much as anybody else. Record it.

POST /v1/suppressions:
```bash
curl -X POST https://emails.sh/v1/suppressions \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "reason": "manual"}'
```

### Clear one

Deliberately, one at a time, and only when you know something changed. A typo somebody has now fixed is a good reason. "The campaign is going out tomorrow" is not.

DELETE /v1/suppressions/:id:
```bash
# By id, taking the id off a row in GET /v1/suppressions
curl -X DELETE https://emails.sh/v1/suppressions/a6c40b98-7f21-4d53-9e08-3b15c7a2f640 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Or by address, when the address is what you have
curl -X DELETE "https://emails.sh/v1/suppressions?email=ada@example.com" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

Both forms lift exactly one row and answer { "deleted": ... }. DELETE /v1/suppressions?id= is the older spelling of the first one and still works, so nothing already written has to change; the id in the path is the URL to write now. A global row, a row on another workspace, and an id that never existed all answer 404 with the same sentence, because none of them is something this workspace can lift and none of them is state you should be able to discover.

There is no bulk clear and there is no bypass flag on a send. Both have been asked for, and both are the same request: a way to mail people who bounced or complained. The answer is no, and it is the reason the deliverability on this platform is worth anything.

### Import one before you send

If you are arriving from another provider, import their suppression list before your first real send. Those addresses already bounced or complained somewhere else, and mailing them from a new setup is the most reliable way to get a domain filtered in its first week. The migration commands in /docs/migrate-resend, /docs/migrate-postmark, /docs/migrate-sendgrid, and /docs/migrate-mailgun each do it first, before they touch anything else.

- `GET /v1/suppressions` ?reason=&email=&limit=&before= over blocked addresses.
- `POST /v1/suppressions` { email, reason? } blocks one yourself.
- `DELETE /v1/suppressions/:id` Lift one by id. A global row answers 404, and so does another workspace's.
- `DELETE /v1/suppressions` ?email= clears one by address. ?id= is the older spelling of the route above and still works.

#### `suppressions.list`

`{ reason?: "bounce" | "complaint" | "unsub" | "manual", email?: string, limit?: number, before?: string }`

Addresses nothing will reach on this workspace, and why each one is there. Rows with is_global set are ours rather than yours.

| Parameter | Type | Required |
| --- | --- | --- |
| reason | `"bounce" | "complaint" | "unsub" | "manual"` | no |
| email | `string` | no |
| limit | `number` | no |
| before | `string` | no |

Returns: { suppressions[], next_cursor, total }

#### `suppressions.create`

`{ email: string, reason?: string }`

Block an address yourself, for somebody who asked you to stop by replying rather than by clicking.

| Parameter | Type | Required |
| --- | --- | --- |
| email | `string` | yes |
| reason | `string` | no |

Returns: Suppression

#### `suppressions.delete`

`{ id?: string, email?: string }`

Clear one, when you know the address is good again. A global row answers 404 and cannot be cleared.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | no |
| email | `string` | no |

Returns: { deleted }

This route answers with the flat error shape: { "error": "code", "message": "...", "hint": "..." } rather than a nested object. See /docs/errors.

## Custom tracking domain

https://emails.sh/docs/tracking-domain

When a broadcast has open or click tracking on, the pixel and the links in it point at a host we run. Without a tracking domain of your own that host is a shared emails.sh address, which means the links in your mail are visibly somebody else's. A custom tracking domain replaces it with a name under your own domain.

Three routes, so an assistant that has just verified a sending domain can finish the job without anybody opening a settings page. GET /v1/domains/:id/tracking hands back the record to publish and says whether it resolves yet, POST turns it on once the CNAME is live, and DELETE goes back to the shared host. The dashboard at https://emails.sh/dashboard/domains does the same thing. The host defaults to click.<domain> and has to sit under a sending domain of your own that is already verified: a shared sending subdomain is not yours to brand, and the route refuses it with 422 tracking_needs_custom_domain. POST checks DNS live and answers 422 tracking_cname_not_found until the record resolves, rather than rewriting every link in your next send to a hostname that answers nothing.

PATCH /v1/domains/:id is the same thing in one field: {"tracking_host": "click.mail.acme.com"} sets it and {"tracking_host": null} clears it, with the same live CNAME check and the same refusals. Use whichever fits the code you are writing. A bare label is expanded under the domain, so "click" on mail.acme.com means click.mail.acme.com. open_tracking and click_tracking are not settings here and a body carrying either is refused by name with 422 tracking_switch_not_supported.

### The record

One CNAME. The host has to sit under a custom sending domain you have already verified, which is what keeps somebody from pointing a tracking host at a domain they do not control. click.<your domain> is the suggested name and the one most people use.

| Field | Value |
| --- | --- |
| Type | CNAME |
| Name | click.mail.acme.com, or any host under a domain you have verified |
| Value | track.emails.sh |
| TTL | Whatever your host defaults to |

Confirm the CNAME:
```bash
# Check it resolves before you rely on it
dig CNAME click.mail.acme.com +short
# expect: track.emails.sh.
```

### What the links become

Two URL shapes get rewritten, and both take the tracking host as their base. The open pixel is /o/<message id>.gif and a tracked link is /c/<link id>.

Before and after:
```text
Without a tracking domain:
  https://emails.sh/o/f3a91c07-4e28-4b6d-9c15-8d02a7e5b431.gif
  https://emails.sh/c/7d2e9a10

With click.mail.acme.com pointed at track.emails.sh:
  https://click.mail.acme.com/o/f3a91c07-4e28-4b6d-9c15-8d02a7e5b431.gif
  https://click.mail.acme.com/c/7d2e9a10
```

### Why bother

- **The links look like yours**: A recipient hovering a link in your newsletter sees your domain. On a shared host they see ours, which is the sort of thing that makes a careful person not click.
- **Reputation is yours**: A shared tracking host carries everybody's links, and a link scanner that has seen something bad on it has seen it on the same hostname as yours.
- **It survives a move**: The CNAME is yours. Pointing it somewhere else later is a DNS change rather than a rewrite of every link already sitting in an inbox.

### Tracking is off by default

Nothing is tracked unless you ask for it. On a broadcast that is track_opens and track_clicks, both defaulting to off. A transactional send through POST /v1/emails is not tracked and has no pixel in it.

Leave it off for transactional mail. A receipt does not need a pixel, and an invisible image in a password reset is the kind of thing that gets a message filed as suspicious by a scanner that has no way to tell your pixel from anybody else's.

One link is never rewritten under any setting: the one-click unsubscribe. Putting a redirect in front of it breaks RFC 8058, which is the rule Gmail and Yahoo cite in their bulk sender requirements. See /docs/topics.

Open numbers are a floor whatever host serves the pixel. Read /docs/analytics before you make a decision out of them.

## Dedicated IPs

https://emails.sh/docs/dedicated-ips

By default your mail goes out through shared pools, which is the right answer for almost everybody: a pool carries enough steady volume to hold a reputation, and yours joins it. A dedicated address is the other trade. The reputation is entirely yours, which cuts both ways.

POST /v1/ips takes one out of inventory, answering 201 with the address. It reads {"mail_class": "transactional" | "marketing"} and an optional region, and the class travels with the address for life: an address that spent three weeks warming on newsletters carries a newsletter reputation, so the two never share one. Warmup starts immediately and cannot be skipped. It answers 402 plan_required on the free plan, and 503 no_address_available when inventory is empty, which is a retry rather than a mistake. DELETE /v1/ips/:id hands the address back; sends fall back to the shared pool for their class immediately, so releasing one does not stop mail, but the warmup it had accumulated stops being yours. Pause while you are investigating a reputation problem, release when you are finished with it.

### What you have

GET /v1/ips:
```bash
curl https://emails.sh/v1/ips \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "default_region": "eu",
  "residency": { "sending": "eu-west-1", "storage": "eu", "compute": "eu" },
  "ips": [
    {
      "id": "5c1a7e04-8b39-4f62-a0d7-1e94b3c76085",
      "ip": "203.0.113.42",
      "region": "eu",
      "region_label": "Europe (Ireland)",
      "mail_class": "marketing",
      "pool_name": "acme-marketing",
      "warmup_state": "warming",
      "warmup_day": 6,
      "daily_cap": 10000,
      "sent_today": 4102,
      "day": "2026-07-28",
      "assigned_at": "2026-07-22T09:00:00.000Z",
      "warmup_started_at": "2026-07-23T00:00:00.000Z",
      "warmup_completed_at": null
    }
  ],
  "shared_pools": [
    { "mail_class": "transactional", "label": "Transactional", "pool_name": "shared-tx-eu" },
    { "mail_class": "marketing", "label": "Marketing", "pool_name": "shared-mkt-eu" }
  ]
}
```

mail_class is derived from the pool name rather than set separately, and it is transactional, marketing, or null. Keeping the two classes on separate addresses is the whole reason to have more than one: a campaign that gets complaints then cannot damage the deliverability of your password resets.

### Warmup

A new address has no history, and an address with no history that sends a hundred thousand messages on its first day looks exactly like a spammer, because usually it is one. So a new address ramps, with a cap that rises each day for 17 days.

| Day | Daily cap |
| --- | --- |
| 1 | 50 |
| 2 | 100 |
| 3 | 500 |
| 4 | 1000 |
| 5 | 5000 |
| 6 | 10000 |
| 7 | 20000 |
| 8 | 40000 |
| 9 | 70000 |
| 10 | 100000 |
| 11 | 150000 |
| 12 | 200000 |
| 13 | 300000 |
| 14 | 400000 |
| 15 | 500000 |
| 16 | 750000 |
| 17 | 1000000 |

Past day 17 the address is ready and the schedule stops applying. Overflow above the cap goes out through the shared pool rather than being refused, so warmup slows an address down without stopping your mail.

| warmup_state | What it means |
| --- | --- |
| pending | Assigned and not started. Nothing is going through it yet. |
| warming | On the schedule above. warmup_day says where. |
| ready | Warmed. It carries whatever you send it. |
| paused | You paused it, or we did. Mail routes through the shared pool instead. |
| retired | Out of service. It refuses a PATCH. |

### The two things you can change

paused and daily_cap. That is the whole of PATCH /v1/ips/:id, and anything else in the body is refused. daily_cap must be a non-negative integer or null, and it can only lower the allowance: a number above the warmup cap for the day does not raise it.

PATCH /v1/ips/:id:
```bash
# Hold this address at 5000 a day, below whatever warmup would allow
curl -X PATCH https://emails.sh/v1/ips/5c1a7e04-8b39-4f62-a0d7-1e94b3c76085 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"daily_cap": 5000}'

# Take it out of rotation without giving it up
curl -X PATCH https://emails.sh/v1/ips/5c1a7e04-8b39-4f62-a0d7-1e94b3c76085 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"paused": true}'
```

### Reverse DNS

GET on one address runs a live PTR check and tells you what the record should say, what it does say, whether a forward lookup confirms it, and whether the whole thing is in order. A dedicated address whose reverse DNS does not forward-confirm is one several large receivers treat with suspicion, so this is the field to watch after provisioning.

GET /v1/ips/:id:
```json
{
  "id": "5c1a7e04-8b39-4f62-a0d7-1e94b3c76085",
  "ip": "203.0.113.42",
  "reverse_dns": {
    "ip": "203.0.113.42",
    "expected": "a42.out.emails.sh",
    "found": "a42.out.emails.sh",
    "forwardConfirmed": true,
    "ok": true
  }
}
```

### Do you want one

- **Under a few hundred thousand a month, probably not**: A dedicated address needs steady volume to hold a reputation. Send too little and receivers have nothing recent to judge it by, which is worse than being in a healthy pool.
- **Separating marketing from transactional, yes**: This is the strongest reason. A campaign and a password reset should not share a reputation, and separate addresses are how that is enforced rather than hoped for.
- **To escape a bad shared reputation, no**: A dedicated address starts with no reputation at all, and 17 days of warmup, and every complaint after that is yours alone. If your mail is getting filtered, fix the mail.
- **A compliance requirement, sometimes**: Some contracts specify a dedicated sending address. That is a real reason and it is fine.

- `GET /v1/ips` Dedicated addresses, shared pools, the default region, and data residency.
- `GET /v1/ips/:id` One address, with a live reverse DNS check.
- `PATCH /v1/ips/:id` { paused?, daily_cap? } and nothing else.

#### `ips.list`

`{ }`

Dedicated addresses on the workspace, the shared pools anything else goes through, the default region, and where sending, storage, and compute physically happen.

Returns: { default_region, residency, ips[], shared_pools[] }

#### `ips.get`

`{ id: string }`

One address, plus a live reverse DNS check: what the PTR should say, what it says, and whether the forward lookup confirms it.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: Ip & { reverse_dns }

#### `ips.update`

`{ id: string, paused?: boolean, daily_cap?: number | null }`

The only two things about an address you can change. POST /v1/ips takes one out of inventory and DELETE /v1/ips/:id hands it back; this route is for pausing one and lowering its cap.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| paused | `boolean` | no |
| daily_cap | `number | null` | no |

Returns: Ip

GET /v1/ips is also where the sending region reads back. It is not selectable through this route or any other. See /docs/regions.

## Regions and data residency

https://emails.sh/docs/regions

There are two regions, us and eu. The default is eu. This page says what that actually changes, which is less than the word suggests, and it is honest about the fact that you cannot choose today.

The region of an existing address is not changeable, and there is no field on a send and no setting in the dashboard that moves one. It is resolved from the sending domain, then the workspace, then the default, and it reads back on GET /v1/ips as default_region and as region on each address. The one place you choose is POST /v1/ips, which takes an optional region for the address it is about to assign; omit it and the workspace default is used.

### What changes

One thing: which SES endpoint the message is handed to, us-east-1 or eu-west-1. That is the physical point at which your mail leaves for the recipient's server.

Storage is in the EU in both cases. Message bodies, delivery events, contacts, and everything else this API returns live in the EU whichever region sent the mail. So a workspace on the us region is not a workspace whose data is in the United States, and it would be wrong to tell an auditor that it is.

The residency block on GET /v1/ips:
```json
{
  "default_region": "eu",
  "residency": { "sending": "eu-west-1", "storage": "eu", "compute": "eu" }
}
```

### How it is decided

In order, and the first answer wins: the sending domain, then the workspace, then eu. So a domain that carries a region uses it, and everything else falls through to the workspace setting, and a workspace with nothing set is on eu.

### Does it matter for deliverability

Much less than people expect. Receiving servers judge a message on its authentication, its sending reputation, and its content. Geographic proximity between the sending server and the receiving one affects latency by milliseconds and affects filtering by essentially nothing.

What does matter is on other pages: verify the domain including all three DKIM records, publish DMARC, send a real text part, and warm up rather than moving your whole volume in one day. See /docs/domains and /docs/troubleshooting.

### What to tell a compliance reviewer

- **Where is data stored**: The EU, for every workspace, regardless of sending region.
- **Where does processing happen**: The EU.
- **Where does mail leave from**: eu-west-1 by default, us-east-1 for a workspace or domain on the us region.
- **Can we require a region**: Not through the API today. Ask us at https://emails.sh/contact and it is set for you.
- **How do we verify what ours is**: GET /v1/ips returns default_region and the residency block above. That is the authoritative answer, and it is readable with any key holding the workspace scope.

Retention is separate from residency and is the same in both regions: message bodies for 30 days, delivery events for 12 months. See /docs/delivery.

## SMTP relay

https://emails.sh/docs/smtp

Point any app that already speaks SMTP at emails.sh. Nothing in your code changes: you swap the host, the port, and the credentials in your mailer config, and the mail goes out through emails.sh with your verified domain, your delivery log, and your suppression list.

This is the fastest migration path off Postmark, SendGrid, Mailgun, SES, or an old Gmail relay. If your app is Laravel, Rails, Django, WordPress, or anything using PHPMailer or Nodemailer, this is four lines of config.

### Settings

| Setting | Value |
| --- | --- |
| Host | smtp.emails.sh |
| Port | 587 with STARTTLS, recommended. 465 with implicit TLS where it is available. |
| Username | emailssh, the literal string |
| Password | your API key, the string starting esh_ |
| Encryption | Required. TLS on 465, STARTTLS on 587. |
| Authentication | PLAIN or LOGIN |
| Max message size | 40 MB |
| Max recipients per transaction | 50 |
| Simultaneous connections | 10 per source address |

The username is always the literal string emailssh. It is not your email address and not your workspace name. Your API key is the password, so there is no second credential to create: make a key at https://emails.sh/dashboard/api-keys and paste it in.

Use port 587 unless something in your stack blocks it. Some hosts, a few PaaS providers and most residential ISPs, block 587 but allow 465. Port 465 only listens where TLS material is configured, so if a connection to it is refused, use 587.

The From address must be on a domain you have verified, exactly as with the API. While you are still testing, send from onboarding@emails.sh.

### Nodemailer

Node:
```ts
import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: 'smtp.emails.sh',
  port: 587,
  secure: false, // STARTTLS is negotiated on 587; true only for port 465
  auth: {
    user: 'emailssh',
    pass: process.env.EMAILSSH_API_KEY
  }
});

await transporter.sendMail({
  from: 'Acme <hello@acme.com>',
  to: 'someone@example.com',
  subject: 'Welcome to Acme',
  text: 'Thanks for signing up.',
  html: '<p>Thanks for signing up.</p>'
});
```

If you would rather not run SMTP at all from Node, npm install @emails.sh/sdk and call mail.send over HTTPS instead. SMTP exists here for the apps that cannot change. See /docs/node.

### Django

settings.py:
```py
import os

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.emails.sh'
EMAIL_PORT = 587
EMAIL_USE_TLS = True          # STARTTLS on 587
EMAIL_HOST_USER = 'emailssh'
EMAIL_HOST_PASSWORD = os.environ['EMAILSSH_API_KEY']
DEFAULT_FROM_EMAIL = 'Acme <hello@acme.com>'
```

For port 465 use EMAIL_USE_SSL = True with EMAIL_PORT = 465, and leave EMAIL_USE_TLS unset. Django refuses to start if both are true. Sending is then the ordinary Django call.

An ordinary send:
```py
from django.core.mail import send_mail

send_mail(
    subject='Welcome to Acme',
    message='Thanks for signing up.',
    from_email='Acme <hello@acme.com>',
    recipient_list=['someone@example.com'],
)
```

### Laravel

Laravel .env:
```text
# .env. The key comes from https://emails.sh/dashboard/api-keys.
MAIL_MAILER=smtp
MAIL_HOST=smtp.emails.sh
MAIL_PORT=587
MAIL_USERNAME=emailssh
MAIL_PASSWORD=esh_your_key_here
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=hello@acme.com
MAIL_FROM_NAME="Acme"
```

config/mail.php needs no change: those variables are what the shipped config already reads. For port 465, set MAIL_PORT=465.

A send:
```php
<?php

use Illuminate\Support\Facades\Mail;

Mail::raw('Thanks for signing up.', function ($message) {
    $message->to('someone@example.com')->subject('Welcome to Acme');
});
```

### Rails

ActionMailer:
```rb
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address:              'smtp.emails.sh',
  port:                 587,
  user_name:            'emailssh',
  password:             ENV['EMAILSSH_API_KEY'],
  authentication:       :plain,
  enable_starttls_auto: true
}
config.action_mailer.default_options = { from: 'Acme <hello@acme.com>' }
```

For port 465, use port: 465 and tls: true, and drop enable_starttls_auto.

### PHPMailer

PHPMailer:
```php
<?php

use PHPMailer\PHPMailer\PHPMailer;

require 'vendor/autoload.php';

$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host       = 'smtp.emails.sh';
$mail->Port       = 587;
$mail->SMTPAuth   = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // ENCRYPTION_SMTPS for 465
$mail->Username   = 'emailssh';
$mail->Password   = getenv('EMAILSSH_API_KEY');

$mail->setFrom('hello@acme.com', 'Acme');
$mail->addAddress('someone@example.com');
$mail->Subject = 'Welcome to Acme';
$mail->isHTML(true);
$mail->Body    = '<p>Thanks for signing up.</p>';
$mail->AltBody = 'Thanks for signing up.';

$mail->send();
```

### WordPress

Install WP Mail SMTP, choose Other SMTP as the mailer, and fill in smtp.emails.sh, TLS, port 587, authentication on, username emailssh, and your key as the password. The From Email is an address on your verified domain.

Keep the key out of the database by putting it in wp-config.php instead, which the plugin reads in preference to its stored settings.

wp-config.php:
```php
<?php
// wp-config.php. The key comes from https://emails.sh/dashboard/api-keys.
define( 'WPMS_ON', true );
define( 'WPMS_MAILER', 'smtp' );
define( 'WPMS_SMTP_HOST', 'smtp.emails.sh' );
define( 'WPMS_SMTP_PORT', 587 );
define( 'WPMS_SSL', 'tls' );
define( 'WPMS_SMTP_AUTH', true );
define( 'WPMS_SMTP_USER', 'emailssh' );
define( 'WPMS_SMTP_PASS', 'esh_your_key_here' );
```

### Anything else

msmtp, ssmtp, Postfix relayhost, a printer, a CI job:
```text
host:       smtp.emails.sh
port:       587    (or 465)
security:   STARTTLS on 587, TLS on 465
auth:       PLAIN or LOGIN
username:   emailssh
password:   an esh_ key from https://emails.sh/dashboard/api-keys
```

A one-line check from a shell, which is what to run when a framework is failing and you want to know whether the credentials or the framework is the problem.

swaks:
```bash
swaks --server smtp.emails.sh:587 --tls \
  --auth-user emailssh --auth-password "$EMAILSSH_API_KEY" \
  --from hello@acme.com --to someone@example.com \
  --header 'Subject: SMTP test' --body 'It works.'
```

### What happens to your message

The relay parses your MIME message and sends it through the same POST /v1/emails endpoint the API and the SDKs use, so an SMTP send is not a second-class send. It gets the same domain verification, the same suppression list, the same delivery log, the same webhooks, and the same quota. The gateway holds no database and no sending logic of its own.

- **The envelope decides delivery**: Every address your client issues a RCPT TO for is delivered to, and the To and Cc headers only decide how each one is labelled. Bcc works as you expect: an address in the envelope and in no header is delivered to and shown to nobody.
- **The From header decides which domain must be verified**: The envelope MAIL FROM is only the bounce path.
- **Reply-To, Cc, Bcc, text and HTML parts, and attachments carry through**: They arrive as the equivalent fields on the API call.
- **The 250 reply carries the message id**: 250 2.0.0 Ok: queued as 3f9c1e07-42b8-4d65-9a10-7c53e8b2f491. It is a bare UUID with no prefix, and it is the id GET /v1/emails/:id takes, so your SMTP log lines are enough to look a message up later.

### Four things the relay does that will surprise you

These are consequences of translating MIME into a JSON API, and each one is a thing somebody has been caught by.

- **Inline attachments are dropped**: An attachment referenced from your HTML with cid: does not survive, and the image shows as broken. Host the image and reference it with an https URL. This is the one that most often turns a good-looking template into a broken one.
- **Only string-valued X- headers survive**: Custom X- headers pass through when their value is a string. Anything structured is dropped. X-Emailssh-* names are reserved and always dropped.
- **Recipients can become mutually visible**: If no envelope address matches any header, every recipient is promoted into the To header and they can all see each other. That happens when a client issues RCPT TO for addresses that appear in no To or Cc header at all. If you are sending to a list, send one transaction per person, or use POST /v1/emails/batch.
- **Ten connections per address**: A source address opening an eleventh simultaneous connection gets 421. A pool of workers that each hold a connection open needs to be smaller than ten, or to share.

### Reading the reply codes

Your mail library will surface these. What matters is that a 4xx means try again and a 5xx means the message will never be accepted as sent.

| Reply | What it means | What to do |
| --- | --- | --- |
| 250 2.0.0 | Accepted. The id is in the reply. | Nothing. |
| 421 4.7.0 | Too many connections from your address, or too many failed logins. Five auth failures in fifteen minutes locks you out. | Open fewer connections. Fix the credentials, then wait for the lockout to pass. |
| 451 4.3.0 | emails.sh could not be reached. | Retry. Your client will. |
| 451 4.7.1 | Rate limited, or the workspace is paused. | Retry. Slow down if it repeats. |
| 452 4.3.1 | Your quota, daily cap, or spend cap is used up. | Retry after it resets, or raise the plan. |
| 452 4.5.3 | More than 50 recipients in one transaction. | Split it, or use POST /v1/emails/batch for up to 100 in one call. |
| 454 4.7.0 | Credentials could not be checked right now. | Retry. |
| 535 5.7.8 | The username or the API key is wrong. | Username is emailssh. Password is an esh_ key that has not been revoked. |
| 550 5.1.1 | The recipient is suppressed after an earlier bounce or complaint. | Do not retry. See /docs/suppressions. |
| 550 5.1.3 | A recipient address is not valid. | Fix the address. |
| 550 5.6.0 | The message is missing a subject or a body, or a header is not allowed. | Fix the message. |
| 550 5.7.1 | The sending domain is not verified, or the recipient is blocked. | Verify the domain at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while testing. |
| 552 5.3.4 | The message is over 40 MB. | Link to the file instead of attaching it. |

### Limits

- **40 MB per message**: Including base64 attachments. The relay advertises SIZE, so a well-behaved client refuses before sending the bytes.
- **50 recipients per transaction**: Counting To, Cc, and Bcc. Past that you get 452 4.5.3, which is temporary: split the send.
- **Ten simultaneous connections per source address**: An eleventh gets 421.
- **Port 25 is not offered**: And never will be. There is no unauthenticated relay.

The API key is the password. There is no separate SMTP credential to create and none to revoke independently, so rotating an SMTP password means rotating the key, which is /docs/api-keys.

## Troubleshooting

https://emails.sh/docs/troubleshooting

- **The API returns 401 and the key looks right**: Check for a newline or a quote in the environment variable: EMAILSSH_API_KEY="esh_..." in a .env file that is parsed loosely can include the quotes. Print the length of the value, do not print the value.
- **invalid_from_domain, but the domain is in the dashboard**: Added is not verified. Call POST /v1/domains/:id/verify and read the records array: each one carries found: true or false, and the false one is your answer.
- **The domain will not verify and the records are published**: Almost always the DNS host appended the zone to a name that was already absolute. If your host shows _emailssh.mail.acme.com.mail.acme.com, enter just _emailssh. Check with dig TXT _emailssh.mail.acme.com +short.
- **The email is queued but never arrives**: Get its status. delivered means it reached the recipient's server and a spam folder is the next place to look. bounced carries the remote server's own words about why.
- **Everything lands in spam**: In order: verify the domain including DKIM, send from a subdomain rather than the apex, publish DMARC, put a real text part on every email, and warm up rather than moving all your volume in one day. A brand new domain sending ten thousand messages on day one is the single most common cause.
- **It works locally and not in production**: The variable is not in the deployed environment. On Vercel and Netlify a variable added after the last build is not in the running build: redeploy. On Workers it must be a secret, set with wrangler secret put.
- **Two of every email**: Something retried. Pass idempotency_key derived from what caused the send, and the retry stops being a second email.
- **429 from a job that sends a lot**: 600 requests a minute per key, which is 10 a second. Use POST /v1/emails/batch, which is one request for up to 100 emails, and honour retry-after when it comes.
- **Webhooks are not arriving**: GET /v1/webhooks/deliveries first. If there are attempts with a status code, your endpoint is being called and rejecting them. If there are none, check the event is one you subscribed to.
- **The signature never verifies**: You are almost certainly hashing re-serialised JSON. Sign the raw bytes: in Express that means express.raw({ type: "application/json" }) on that route, not express.json().
- **recipient_suppressed on an address that works elsewhere**: It hard bounced or complained on this workspace before. That is a deliberate block. If you know it is fixed, remove it from the suppression list in the dashboard, once.

### Getting help

Every response carries an x-request-id. Send it with the email id and the exact time, and support can find the message rather than asking you for more detail: https://emails.sh/contact.

## Glossary

https://emails.sh/docs/glossary

- **Transactional email**: One email caused by one thing one person did: a receipt, a password reset, a verification code. As opposed to bulk email, which is one message sent to a list.
- **DKIM**: A signature over the message, published as a DNS record, that lets the receiving server prove the mail came from your domain and was not altered. Three CNAME records here, one per key.
- **SPF**: A DNS record listing who may send mail for your domain. A domain may have exactly one; adding a second breaks both.
- **DMARC**: A DNS record telling receivers what to do when DKIM and SPF disagree, and where to send reports about it. Start at p=none and tighten once the reports are clean.
- **MX record**: Where mail for a domain should be delivered. You only need one if you want to receive.
- **Bounce**: The receiving server refused the message. Permanent means the address does not exist; transient means try later, which we do for you.
- **Complaint**: A recipient pressed the spam button, reported back to us through a feedback loop. The address is suppressed.
- **Suppression list**: Addresses on this workspace that hard bounced or complained. Sends to them are refused rather than attempted, which is what protects your sending reputation.
- **Idempotency key**: Your own id for a send. A repeat within 24 hours returns the first result instead of sending again.
- **Warm-up**: Raising volume on a new domain gradually, over days, rather than all at once. Receivers treat a domain with no history and sudden volume as a spammer, because usually it is one.
- **Workspace**: The account boundary. Domains, keys, webhooks, suppression, and quotas all belong to one.
- **llms.txt**: A plain-text file at the root of a site describing it for an AI assistant to read directly. Ours is the whole API in one fetch: https://emails.sh/llms.txt.

## Migrating from Resend

https://emails.sh/docs/migrate-resend

emails.sh serves a Resend-compatible API at https://api.emails.sh/resend. It speaks their exact request and response format, including their error shape, so their official SDK talks to it without noticing. Their Node client resolves its host as options.baseUrl, then process.env.RESEND_BASE_URL, then api.resend.com, which means the whole migration for a JavaScript codebase is two environment variables and a deploy.

The entire change:
```bash
# Keep the resend package. Keep every import. Keep every call.
RESEND_BASE_URL=https://api.emails.sh/resend
RESEND_API_KEY=esh_live_yourkey
```

Nothing else moves. The resend package stays in your package.json, your imports stay, resend.emails.send() stays, and the object it returns is the same object with the same id field. Roll it back by removing the two variables.

Create the key first with `npx @emails.sh/cli keys create production`, or at https://emails.sh/dashboard/keys. Keys here start with esh_. A re_ key is accepted by the compatibility endpoint too, so a half-finished migration fails with "unknown key" rather than "malformed key", but a Resend key will never authenticate here.

### Every language, and how to point it here

These were read from each SDK's source, not from its documentation. Five of the nine take an environment variable, three need one line of configuration, and one cannot be redirected at all. Note that the Python client reads RESEND_API_URL, not RESEND_BASE_URL, which is the single most common way a migration silently keeps sending through Resend.

| Language | Package | How to point it at emails.sh | Change |
| --- | --- | --- | --- |
| Node / TypeScript | resend | RESEND_BASE_URL=https://api.emails.sh/resend | env var |
| Python | resend | RESEND_API_URL=https://api.emails.sh/resend | env var |
| PHP | resend/resend-php | RESEND_BASE_URL=api.emails.sh/resend | env var |
| Ruby | resend | RESEND_BASE_URL=https://api.emails.sh/resend/ | env var, set before require |
| Go | github.com/resend/resend-go/v3 | RESEND_BASE_URL=https://api.emails.sh/resend/ | env var |
| Rust | resend-rs | RESEND_BASE_URL=https://api.emails.sh/resend | env var |
| .NET / C# | Resend | o.ApiUrl = "https://api.emails.sh/resend" | one line |
| Elixir | resend | config :resend, Resend.Client, base_url: "..." | one line |
| Java | com.resend:resend-java | Not overridable. Use @emails.sh/sdk. | switch SDK |

Ruby, Go, and PHP bake a trailing slash into their default, so give them one too. PHP's BaseUri adds https:// itself when the value has no scheme. Ruby reads the variable at require time, so set it before your app boots rather than in an initializer.

The three that need a line of code:
```ts
// Node, where you would rather not use the environment
new Resend(process.env.RESEND_API_KEY, { baseUrl: 'https://api.emails.sh/resend' });

// .NET, in your service registration
services.AddResend(o => {
  o.ApiToken = Configuration["Resend:ApiToken"];
  o.ApiUrl   = "https://api.emails.sh/resend";
});

// Elixir, in config/runtime.exs
config :resend, Resend.Client,
  api_key: System.get_env("RESEND_API_KEY"),
  base_url: "https://api.emails.sh/resend"
```

Java is the exception. com.resend:resend-java holds its host in `public static final String BASE_API = "https://api.resend.com"` and offers no public way past it, so there is nothing to override. Use @emails.sh/sdk for Java instead: the call shape is the same and it is a smaller change than the fork would be.

### What the compatibility endpoint covers

- `POST /resend/emails` Send one. Answers {"id":"..."}, exactly as theirs does.
- `POST /resend/emails/batch` Up to 100 in one call. Honours x-batch-validation.
- `GET /resend/emails` Recent sends, in their list envelope.
- `GET /resend/emails/:id` One email, with last_event derived from our delivery timeline.
- `POST /resend/emails/:id/cancel` Call off a send booked with scheduled_at.
- `GET /resend/domains` Sending domains, with name rather than domain.
- `POST /resend/domains` Add one, and get back the DNS records to publish.
- `GET /resend/domains/:id` One domain and its records.
- `POST /resend/domains/:id/verify` Check the DNS now rather than waiting for the sweep.
- `DELETE /resend/domains/:id` Remove a domain.
- `GET /resend/api-keys` List keys.
- `POST /resend/api-keys` Create one. permission maps onto our scopes.
- `DELETE /resend/api-keys/:id` Revoke one.
- `PATCH /resend/emails/:id` Move a scheduled send, keeping its id.
- `PATCH /resend/domains/:id` Set the tracking subdomain.
- `GET /resend/contacts` Contacts, with first_name, last_name and properties.
- `POST /resend/contacts` Create one, optionally on segments and topics.
- `GET /resend/contacts/:id_or_email` One contact, by uuid or by address, as theirs allows.
- `PATCH /resend/contacts/:id_or_email` Update names, properties, or unsubscribed.
- `DELETE /resend/contacts/:id_or_email` Erase the person, not their delivery history.
- `GET /resend/contacts/:id_or_email/topics` Their topic subscriptions.
- `PATCH /resend/contacts/:id_or_email/topics` Opt in or out, per topic.
- `GET /resend/contacts/:id_or_email/segments` Which lists they are on.
- `POST /resend/contacts/:id_or_email/segments/:segment_id` Add them to one.
- `DELETE /resend/contacts/:id_or_email/segments/:segment_id` Take them off one.
- `GET /resend/segments` Lists, in their post-rename vocabulary.
- `POST /resend/segments` Create one.
- `GET /resend/segments/:id` One list.
- `DELETE /resend/segments/:id` Delete one.
- `GET /resend/segments/:id/contacts` Who is on it.
- `GET /resend/audiences` The same lists, at their deprecated path.
- `POST /resend/audiences` Create one, for SDKs older than their rename.
- `GET /resend/audiences/:id` One list.
- `DELETE /resend/audiences/:id` Delete one.
- `GET /resend/audiences/:id/contacts` Who is on it.
- `POST /resend/audiences/:id/contacts` Add somebody to it.
- `PATCH /resend/audiences/:id/contacts/:id_or_email` Update one membership. Unsubscribes from this list alone.
- `GET /resend/broadcasts` Campaigns, in their list envelope.
- `POST /resend/broadcasts` Create one. send:true sends it in the same call.
- `GET /resend/broadcasts/:id` One campaign, with both audience_id and segment_id.
- `PATCH /resend/broadcasts/:id` Edit a draft.
- `DELETE /resend/broadcasts/:id` Cancel one.
- `POST /resend/broadcasts/:id/send` Send it, now or at scheduled_at.
- `GET /resend/broadcasts/:id/metrics` Opens, clicks, bounces, each with its rate.
- `GET /resend/topics` Subscription groups.
- `POST /resend/topics` Create one, opt-in or opt-out by default.
- `GET /resend/topics/:id` One topic.
- `PATCH /resend/topics/:id` Rename or redescribe it.
- `DELETE /resend/topics/:id` Retire it. The opt-outs on it survive.
- `GET /resend/templates` Stored templates.
- `POST /resend/templates` Create and publish one.
- `GET /resend/templates/:id_or_alias` One template, by id or alias.
- `PATCH /resend/templates/:id_or_alias` Edit it. A body change writes and publishes a version.
- `DELETE /resend/templates/:id_or_alias` Delete it.
- `POST /resend/templates/:id_or_alias/publish` Point live sends at the latest version.
- `GET /resend/webhooks` Endpoints and the events they take.
- `POST /resend/webhooks` Subscribe one. The signing secret is shown once.
- `GET /resend/webhooks/:id` One endpoint.
- `PATCH /resend/webhooks/:id` Change the URL, the events, or enable and disable it.
- `DELETE /resend/webhooks/:id` Remove one.
- `GET /resend/suppressions` Who will not be mailed, and why.
- `POST /resend/suppressions` Add an address by hand.
- `GET /resend/suppressions/:id_or_email` One entry, by id or by address.
- `DELETE /resend/suppressions/:id_or_email` Lift one, where lifting it is allowed.

These are a translation over the same /v1 handlers everything else uses, not a second implementation. A send through the compatibility endpoint passes the same suppression check, the same quota, the same spend cap, and the same idempotency table as a send through /v1/emails, because none of that logic lives in the compatibility layer and none of it can be skipped by using it.

### What differs

- **Contacts, segments, audiences, and broadcasts**: All wire-compatible now. Their contact is our contact plus its address plus its attributes; their segment and their older audience are both our audience, because at Resend the two are one thing under two names. Their properties are our contact attributes. A contact created here with no first or last name is displayed under its address, which is what their dashboard shows too.
- **unsubscribed on a contact**: Theirs is one boolean meaning "leave this person out of broadcasts". We record it in two places, because neither alone can answer it: every audience membership, which is what a broadcast reads, and the suppression list, which is what stops mail to somebody who is on no list. The difference to know about is that our suppression also holds back transactional mail to that address. That is the safe direction to be wrong in. Set it per list instead with PATCH /resend/audiences/:id/contacts/:id_or_email, which touches that membership only.
- **topic_id on a send**: Honoured. It files the message under a subscription topic, so somebody who opted out of that topic does not receive it and the List-Unsubscribe header points at the right place. A topic id that does not exist here is a refusal rather than a message that went out unfiled. See /docs/topics.
- **Rescheduling**: PATCH /emails/:id moves a booked send and keeps its id, as theirs does. It is conditional on the send not having been picked up yet: once it is on its way you get a 409 saying so, rather than a 200 about a message that is already in flight.
- **Recipient limits**: Resend documents 50 against to alone. Ours is 50 across to, cc, and bcc together, because a bcc recipient costs exactly as much to deliver to as a to recipient and one call that fans out to hundreds of strangers is the first thing a stolen key does. A call inside their limit can therefore be outside ours, and the refusal says so with the arithmetic in it rather than leaving you to count. Use POST /emails/batch for more.
- **Inline images (content_id)**: Refused, not ignored. We do not build multipart/related parts, so a cid: reference would render as a broken image in the recipient's inbox with nothing in any log connecting it to the field that caused it. Reference the image by https URL in the HTML instead: it renders everywhere and is not hidden by the same image rules that block inline parts anyway.
- **Attachment fetching**: attachments[].path works and the ceiling is 40 MB, the same number Resend documents, counted across the whole message after base64 encoding. The URL must be https and resolve to a publicly routable address, and redirects are not followed. Send the bytes as content for anything behind authentication.
- **Domain settings**: region, tls, custom_return_path, open_tracking, click_tracking, and capabilities are each either applied or refused by name on POST /domains, never accepted and dropped. We send from eu-west-1 only, TLS is opportunistic, the Return-Path is managed for you, and there is no domain-level tracking switch. tracking_subdomain is real and PATCH /domains/:id sets it, once the CNAME resolves. See /docs/tracking-domain.
- **Deleting a domain**: Refused while mailboxes still receive mail on it, because deleting it would take those addresses and the conversations in them with it. Resend has no equivalent of this, since Resend domains do not receive. Remove the mailboxes first.
- **domain_id on an API key**: Refused. A key here carries scopes (what it may do) rather than a domain (where it may send from), and there is no way to issue a domain-scoped one, so accepting the field would hand you a wider key than you asked for. Every send is still checked against the domains this workspace has verified, so a key cannot send from a domain you do not own. Use one workspace per domain if the separation is load-bearing.
- **Webhooks**: Four names mean the same thing on both sides: email.sent, email.delivered, email.bounced, email.complained. Those branches of your switch on event.type need no edit. Two do not exist here: there is no email.opened and no email.clicked, and POST /v1/webhooks refuses an events array containing either, by name, rather than accepting it and never firing. Delete those two branches and read the numbers instead: a click is a counter on the message and a row in GET /v1/analytics, and opens sit beside them as a floor rather than a count, because a privacy proxy fetches the pixel on the recipient's behalf whether or not anybody looked. We also send events Resend does not: email.received and email.filtered for inbound mail, domain.verified, the three workspace ones, and two for automation runs. The whole list is on the webhooks page. The signature changes too: theirs is svix, ours is x-emailssh-signature over the raw body. That is the one part of this surface we cannot absorb for you, because the signature is computed over the body your receiver reads. The secret is shown once, when you create the endpoint. See https://emails.sh/docs/webhooks.
- **Topic visibility**: Refused. Their visibility field hides a topic from contacts who have not opted in; our hosted preference centre lists every live topic to everybody, so accepting "private" would show somebody a topic they were promised would be hidden. Leave the field out, or pass "public".
- **Contact properties as a schema**: Their contact-properties API declares fields ahead of time. We have no such registry: an attribute is created by being written. The cost is that a typo in a property name is a new property rather than an error. Everything else about them works, through properties on a contact.
- **Test addresses**: Theirs live on resend.dev. Ours live on emails.sh: delivered@, bounced@, complained@, and suppressed@, with the same sub-addressing and the same behaviour. Rewrite the domain and your existing tests keep asserting what they asserted.
- **Message ids**: Ours are UUIDs. Mail you sent through Resend before switching stays in their logs, and its id will not resolve here.

### What we do that they do not

Receiving. emails.sh runs inbound MX on your domain, parses the MIME, extracts attachments, and threads replies against In-Reply-To and References, so a customer answering your notification arrives as part of a conversation you can query and reply to through the same API. Resend is a sending product, and this is the clearest structural difference between them.

The MCP server. The assistant writing your integration can also operate your account while it works: add the domain, read back the exact DNS rows, trigger the verification check, mint a scoped key, replay a webhook delivery, and look up why a specific message bounced. That removes the step where setup stalls, which is "now open the dashboard and paste these records by hand". See https://emails.sh/docs/mcp.

The agent-first integration surface. Every page on this site is available as markdown at the same URL plus .md, the whole API is one fetch at https://emails.sh/llms.txt, and there is an installable skill for Cursor and Claude Code that means the assistant knows the API without fetching anything. Errors are written to be acted on: a refusal carries a code to branch on, a message naming the value that was wrong, and a sentence saying what to do with the URL in it.

### Bringing the account across

The code is two variables, but an account is also domains, webhook endpoints, and a suppression list that took years of bounces to build. One command reads them from Resend and imports what it can. It prints a plan and waits for a yes before writing anything, and it is safe to run again.

Import the account:
```bash
npx @emails.sh/cli migrate resend
# reads RESEND_API_KEY, or pass --from-key with your Resend key
# prints a plan and asks before writing anything

npx @emails.sh/cli migrate compat
# the table above, in your terminal
```

Two things cannot be copied and the command says so rather than leaving you to find out. Domain verification is a claim on DNS rather than a row, so each domain added here returns its own records to publish; a domain can carry several DKIM selectors at once, so both providers can sign for it while you compare. And API keys are shown once at Resend as they are here, so they cannot be read back and have to be recreated.

Import the suppression list before you send anything real. Those addresses bounced or complained already, and mailing them again from a new provider is the most reliable way to get a domain filtered in its first week.

### Cutting over

- **Send one message and read it back** (/docs/quickstart): Set the two variables in a scratch environment, send to yourself, and fetch it with GET /emails/:id. If the id comes back and last_event moves to delivered, the integration is done.
- **Publish our DNS records alongside theirs** (/docs/domains): Add the domain here and publish our DKIM, SPF, and return-path records. Nothing breaks: a domain may carry several DKIM selectors, so both providers keep signing while you decide.
- **Move a percentage** (/docs/delivery): Send 5% here for a week and compare bounce and complaint rates on the same traffic. There is nothing to warm up: reputation follows the domain, and the domain is not moving.
- **Change the webhook signature check** (/docs/webhooks): Point an endpoint here, verify against x-emailssh-signature, and map email.delivered, email.bounced, and email.complained onto whatever your Resend handler already does.

When you are ready to stop being compatible, move to /v1. It is the same request body with scheduled_at spelled send_at and tags as an object rather than a list of pairs, and it returns the delivery timeline rather than one word for it. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.

## Migrating from Postmark

https://emails.sh/docs/migrate-postmark

emails.sh serves a Postmark-compatible API at https://api.emails.sh/postmark. It speaks their exact request format, their exact response format (To, SubmittedAt, MessageID, ErrorCode, Message), and their exact error shape ({ErrorCode, Message} with a 422), so their official client talks to it without noticing. Their server token becomes an esh_ key and goes in the same X-Postmark-Server-Token header.

Import your suppression list before you send anything real. Those addresses hard-bounced or filed a spam complaint at Postmark already, and mailing them again from a new setup is the most reliable way to get a domain filtered in its first week. `npx @emails.sh/cli migrate postmark` does it in one command, and it does it first, before it touches anything else.

The entire change:
```bash
# No Postmark SDK reads an env var for its host, so this is one line of config.
# PHP
PostmarkClientBase::$BASE_URL = 'https://api.emails.sh/postmark';

# .NET
new PostmarkClient(serverToken, "https://api.emails.sh/postmark");

# Python
ServerClient(server_token, base_url="https://api.emails.sh/postmark")

# Ruby, the only client of the six with a path prefix option
Postmark::ApiClient.new(token, :host => 'api.emails.sh',
                               :path_prefix => '/postmark/')
```

### Every language, and how to point it here

These were read from each SDK's source, not from its documentation. The headline is that none of the six takes an environment variable, so unlike a Resend migration this is a code change, though a one-line one. Four can be pointed at a path under a host. Two cannot: postmark.js resolves its URL as scheme plus requestHost with the endpoint appended, and postmark-java's customApiUrl is a bare hostname the same way, so neither has anywhere to put the /postmark prefix. Postmark ships no Go client at all.

| Language | Package | How to point it at emails.sh | Change |
| --- | --- | --- | --- |
| PHP | postmark-php | PostmarkClientBase::$BASE_URL = "https://api.emails.sh/postmark" | one line, global |
| .NET / C# | Postmark | new PostmarkClient(token, "https://api.emails.sh/postmark") | constructor |
| Python | postmark | ServerClient(token, base_url="https://api.emails.sh/postmark") | constructor |
| Ruby | postmark | :host => "api.emails.sh", :path_prefix => "/postmark/" | options hash |
| Node / TypeScript | postmark | Not reachable: requestHost is a hostname with no path. Use @emails.sh/sdk. | switch SDK |
| Java | com.postmarkapp:postmark | Not reachable: customApiUrl is a hostname with no path. Use @emails.sh/sdk. | switch SDK |
| Go | no official client | Postmark ships no Go SDK. Use @emails.sh/sdk. | switch SDK |

Ruby is the one that thought of it: Postmark::HttpClient carries a :path_prefix option alongside :host, so it reaches /postmark/ cleanly. For Node and Java, install @emails.sh/sdk instead. The send call takes the same fields with our spellings, and it is a smaller change than a fork.

### What the compatibility endpoint covers

- `POST /postmark/email` Send one. Answers To, SubmittedAt, MessageID, ErrorCode, Message, exactly as theirs does.
- `POST /postmark/email/batch` Up to 100 in one call, answering 200 with per-message ErrorCode as theirs does.
- `POST /postmark/email/withTemplate` TemplateAlias is our template slug; TemplateModel is our variables.
- `POST /postmark/email/batchWithTemplates` The {"Messages":[...]} form of the same thing.
- `GET /postmark/bounces` Your suppression list, as their bounce list with their type codes.
- `GET /postmark/message-streams` The one transactional stream we serve.
- `GET /postmark/message-streams/:id/suppressions/dump` The suppression list. The endpoint a migration in either direction reads.
- `GET /postmark/domains` Sending domains, with their four verification booleans.
- `POST /postmark/domains` Add one, and get back the DNS records to publish.
- `GET /postmark/domains/:id` One domain and its records.
- `PUT /postmark/domains/:id/verifyDkim` Check the DNS now rather than waiting for the sweep.
- `GET /postmark/templates` Stored templates. Alias is the slug /email/withTemplate takes.
- `GET /postmark/webhooks` Webhook endpoints, in their Triggers shape.

These are a translation over the same /v1 handlers everything else uses, not a second implementation. A send through the compatibility endpoint passes the same suppression check, the same quota, the same spend cap, and the same idempotency table as a send through /v1/emails, because none of that logic lives in the compatibility layer and none of it can be skipped by using it.

Postmark splits its tokens: a server token sends and reads bounces, an account token lists servers and domains. We have one kind of key and it is scoped to a workspace, so either header authenticates any endpoint here. A tool that reached /postmark/domains with a server token gets the answer rather than the 401 it would get at Postmark.

### What differs

- **Message streams**: The one structural difference, and the one refused rather than mapped. Postmark separates transactional and broadcast mail onto streams with independent reputation, which is a good design and the main reason people choose them. We serve one transactional stream, "outbound". A send naming any other stream answers their error code 1236 with a sentence saying what to do instead, because quietly accepting broadcast traffic onto a transactional path would undo exactly the isolation you picked streams for. The mechanism underneath a stream is separate reputation, and here that is a separate sending subdomain: verify news.yourdomain.com alongside yourdomain.com and send campaigns from it.
- **Suppressions are per stream there, per workspace here**: A full Postmark export has to dump every stream, not just outbound, or every broadcast bounce is left behind. The migrate command lists the streams first and dumps all of them for exactly this reason.
- **Click tracking**: TrackLinks must be "None": anything else is refused, because a message that asked for HtmlOnly would go out untracked and you would not know. TrackOpens is accepted and ignored on this surface rather than refused, since a missing open is not a correctness problem. Both kinds of tracking do exist on broadcasts, at /docs/broadcasts, and neither is reachable through a Postmark client.
- **Inline attachments**: Attachments[].ContentID is refused. We do not set Content-ID on attached parts, so a cid: reference in your HTML would not resolve and the image would show as broken. Host the image and reference it by https URL.
- **Batch size**: One hundred messages per call against their five hundred. A larger batch answers their error code 410 with the number of calls it needs to become. The 200-on-partial-failure contract is preserved: per-message failures come back inside the array with a non-zero ErrorCode, as theirs do.
- **Sender signatures**: We verify domains rather than individual addresses, so any local part on a verified domain sends without being registered first. There is nothing to copy and /postmark/senders answers with a sentence saying so.
- **Templates**: TemplateAlias is our template slug and TemplateModel is our variables, so a template recreated here under the same alias needs no change to the send. What does not survive is the syntax: Postmark uses Mustachio with conditionals and iteration, and ours is {{variable}} substitution only. InlineCss is accepted and ignored, so write inline styles when you recreate a template.
- **Message ids**: Both are UUIDs, but they are different UUIDs. Mail you sent through Postmark before switching stays in their activity log, and its MessageID will not resolve here.

### Bringing the account across

The code is one line, but an account is also domains, webhook endpoints, templates, and a suppression list that took years of bounces to build. One command reads them from Postmark and imports what it can. It prints a plan and waits for a yes before writing anything, and it is safe to run again.

Import the account:
```bash
npx @emails.sh/cli migrate postmark
# reads POSTMARK_SERVER_TOKEN, or pass --from-key
# imports the suppression list first, then domains, webhooks, and templates
# prints a plan and asks before writing anything
```

Suppressions are written straight to your list here rather than left in a file for you to upload later, and they are written before any domain is added, so an interrupted run is one where the half that finished is the half that mattered. A copy is saved as postmark-suppressions.csv either way.

Two things cannot be copied and the command says so rather than leaving you to find out. Domain verification is a claim on DNS rather than a row, so each domain added here returns its own records to publish; a domain can carry several DKIM selectors at once, so both providers can sign for it while you compare. And API keys are shown once at Postmark as they are here, so they cannot be read back and have to be recreated.

### Cutting over

- **Import the suppression list** (/docs/troubleshooting): Before anything else. Run migrate postmark and let it write the bounces and spam complaints to your list here. Everything below this step is reversible; this one is the one that is not.
- **Publish our DNS records alongside theirs** (/docs/domains): Add the domain here and publish our DKIM, SPF, and return-path records. Nothing breaks: a domain may carry several DKIM selectors, so both providers keep signing while you decide.
- **Point one client at the compatibility endpoint** (/docs/quickstart): Change the base URL in a scratch environment, send to yourself, and fetch it back. If MessageID comes back and the message arrives, the integration is done.
- **Decide what each stream was for** (/docs/delivery): Transactional traffic moves as it is. Anything that was on a broadcast stream should get its own sending subdomain here, so a campaign still cannot damage the deliverability of your password resets.

When you are ready to stop being compatible, move to /v1. It is the same message with From spelled from, To as an array rather than a comma-separated string, HtmlBody as html, and Headers as an object rather than a list of pairs. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.

## Migrating from SendGrid

https://emails.sh/docs/migrate-sendgrid

emails.sh serves a SendGrid-compatible API at https://api.emails.sh/sendgrid. It takes their v3 mail/send body, personalizations array and all, and answers the way theirs does: 202 with an empty body and the id in the X-Message-Id header. Errors come back as {"errors":[{"message","field","help"}]}, which is the shape every one of their SDKs deserialises. Their auth is already ours, so the key just changes from SG. to esh_.

Import your suppression lists before you send anything real, and import all five. SendGrid keeps one idea in five places (bounces, blocks, spam_reports, invalid_emails, and the global unsubscribes list) and a migration that reads only bounces leaves the spam complaints behind, which are the suppressions that matter most. `npx @emails.sh/cli migrate sendgrid` reads all five, and it does it first.

The entire change:
```ts
// Node. The order matters and getting it wrong is silent.
// setApiKey resets baseUrl to api.sendgrid.com, so set the key FIRST.
sgMail.setApiKey('esh_live_yourkey');
sgMail.client.setDefaultRequest('baseUrl', 'https://api.emails.sh/sendgrid/');

# Python
SendGridAPIClient('esh_live_yourkey', host='https://api.emails.sh/sendgrid')

# Go
sendgrid.GetRequest(key, "/v3/mail/send", "https://api.emails.sh/sendgrid")
```

In Node the order is load-bearing and getting it wrong fails silently. Client.setApiKey() calls setDefaultRequest("baseUrl", ...) itself, so a base URL set before the key is overwritten back to api.sendgrid.com with no warning and your mail keeps going through SendGrid. Set the key first, the base URL second.

### Every language, and how to point it here

These were read from each SDK's source, not from its documentation. All seven are overridable and none of them reads an environment variable: there is no SENDGRID_API_HOST and there never has been, so every one of these is a code change rather than a config change. It is still one line, except in Node where it is two.

| Language | Package | How to point it at emails.sh | Change |
| --- | --- | --- | --- |
| Node / TypeScript | @sendgrid/mail | setApiKey(key) then client.setDefaultRequest('baseUrl', '...') | two lines, in that order |
| Python | sendgrid | SendGridAPIClient(key, host='https://api.emails.sh/sendgrid') | constructor |
| PHP | sendgrid/sendgrid | new SendGrid($key, ['host' => 'https://api.emails.sh/sendgrid']) | constructor option |
| Ruby | sendgrid-ruby | SendGrid::API.new(api_key: key, host: 'https://api.emails.sh/sendgrid') | keyword arg |
| .NET / C# | SendGrid | new SendGridClient(key, host: "https://api.emails.sh/sendgrid") | constructor |
| Java | com.sendgrid:sendgrid-java | sg.setHost("api.emails.sh/sendgrid") | one line, no scheme |
| Go | github.com/sendgrid/sendgrid-go | GetRequest(key, endpoint, "https://api.emails.sh/sendgrid") | argument |

Several of their clients also have a setDataResidency helper for choosing between api.sendgrid.com and api.eu.sendgrid.com. Do not use it to point here: it writes one of those two hosts and would silently undo the change you just made.

### The body shape, which is the real difference

SendGrid is the hardest of the three because its send body is genuinely a different shape rather than the same shape with different spellings. A message there is a list of personalizations sharing one content array, and each personalization is its own envelope with its own recipients, its own subject, and its own template data. That is not one message with several recipients; it is several messages that happen to share a body.

What the compatibility endpoint takes, unchanged from theirs:
```json
{
  "personalizations": [
    { "to": [{ "email": "someone@example.com", "name": "Jane" }],
      "subject": "Your receipt" }
  ],
  "from": { "email": "receipts@yourdomain.com", "name": "Acme" },
  "content": [
    { "type": "text/plain", "value": "Thanks for your order." },
    { "type": "text/html", "value": "<p>Thanks for your order.</p>" }
  ],
  "categories": ["receipts"]
}
```

So one personalization becomes one send and several become a batch, and the caller sees the same 202 either way. Their cap is a thousand personalizations and our batch is a hundred, so a larger call is refused with the number of requests it has to become rather than being truncated. content is read by type rather than by position, so text/plain and text/html work in whichever order they arrive: their own spec states no ordering rule, their Python helper sorts and their Node helper does not.

### What the compatibility endpoint covers

- `POST /sendgrid/v3/mail/send` Send. Answers 202 with an empty body and X-Message-Id, exactly as theirs does.
- `GET /sendgrid/v3/suppression/bounces` Your suppression list, as their bare array of {created, email, reason, status}.
- `GET /sendgrid/v3/suppression/blocks` The same rows their blocks list would carry.
- `GET /sendgrid/v3/suppression/spam_reports` Complaints, as their {created, email, ip}.
- `GET /sendgrid/v3/suppression/invalid_emails` Addresses that failed at the receiver.
- `GET /sendgrid/v3/suppression/unsubscribes` The global unsubscribe list.
- `GET /sendgrid/v3/whitelabel/domains` Sending domains, in their bare-array domain authentication shape.
- `POST /sendgrid/v3/whitelabel/domains` Add one, and get back the DNS records to publish.
- `GET /sendgrid/v3/templates` Stored templates, in their {result, _metadata} envelope.

These are a translation over the same /v1 handlers everything else uses, not a second implementation. A send through the compatibility endpoint passes the same suppression check, the same quota, the same spend cap, and the same idempotency table as a send through /v1/emails.

### What differs

- **Bypassing suppression**: mail_settings.bypass_list_management and its three narrower siblings are refused rather than ignored. The suppression list is not bypassable here, by design and without an exception. Every address on it bounced, complained, or opted out, and sending to it again is how a domain gets filtered. Lift a single address deliberately in the dashboard if it was suppressed in error.
- **Sandbox mode**: mail_settings.sandbox_mode is refused. There is no validate-and-discard mode. Send to onboarding@emails.sh instead, which delivers only to the address that owns the workspace, so a test is a real message you can read rather than a response you have to trust.
- **Unsubscribe groups (asm)**: Refused rather than ignored, because ignoring it would mail somebody who had opted out of that group. We have subscription topics with a hosted preference centre; recreate the groups as topics and pass topic on POST /v1/emails, which adds the opt-out link and the List-Unsubscribe header for you.
- **Click and subscription tracking**: tracking_settings.click_tracking and subscription_tracking are refused. We do not rewrite links or append footers to your HTML, so a message that asked for either would go out without it and you would not know.
- **Legacy templates**: personalizations[].substitutions is refused. Only dynamic template data is translated, onto our {{variable}} substitution. Handlebars conditionals and loops do not survive, so a d- template using {{#if}} or {{#each}} has to be flattened when you recreate it.
- **batch_id and ip_pool_name**: batch_id has no equivalent: book each send with send_at and cancel it individually with DELETE /v1/messages/scheduled/:id. ip_pool_name is refused because pools are not something you name on a send here. Isolate reputation with a separate sending subdomain, or with a dedicated address if you have one: see /docs/dedicated-ips.
- **Subusers**: A subuser is a tenant with its own reputation and its own key. Here that is a workspace: one per tenant or environment, each with its own keys, domains, and suppression list. They cannot be created over the API.
- **Marketing Campaigns**: Not wire-compatible, and only partly replaced. We have audiences, segments, broadcasts, and automations, at /docs/broadcasts, /docs/segments, and /docs/automations, and they are reached through /v1 rather than through a SendGrid client. What we do not have is a drag-and-drop visual editor: a broadcast body is HTML or a stored template. If a marketing team is living in the Campaigns editor, look at what they build before you promise them a migration.
- **Regions**: SendGrid runs api.sendgrid.com and api.eu.sendgrid.com, and the EU host only works with an EU-regional subuser key. We run one API host, and there is nothing to set on a send. Where the mail physically leaves from is a separate question, answered in /docs/regions.

### Bringing the account across

The code is one line, but an account is also authenticated domains, event webhooks, dynamic templates, and five suppression lists. One command reads them and imports what it can. It prints a plan and waits for a yes before writing anything, and it is safe to run again.

Import the account:
```bash
npx @emails.sh/cli migrate sendgrid
# reads SENDGRID_API_KEY, or pass --from-key
# reads all five suppression lists: bounces, blocks, spam_reports,
# invalid_emails, and unsubscribes, then domains, webhooks, and templates
# prints a plan and asks before writing anything
```

The suppressions are deduplicated across the five lists and written straight to your list here before any domain is added, so an interrupted run is one where the half that finished is the half that mattered. A copy is saved as sendgrid-suppressions.csv either way. An address on spam_reports is imported as a complaint whatever its free-text reason says, because the list it is on is better evidence than the string.

### Cutting over

- **Import all five suppression lists** (/docs/troubleshooting): Before anything else. Run migrate sendgrid and let it write the bounces, blocks, spam reports, invalid addresses, and unsubscribes to your list here. Everything below this step is reversible; this one is not.
- **Inventory what is actually sending** (/docs/sending): List the API keys, the subusers, and any Marketing Campaigns sends before you touch code. Transactional mail moves cleanly. If Campaigns is doing real work, keep it rather than assuming this replaces it.
- **Publish our DNS records alongside theirs** (/docs/domains): SendGrid domain authentication publishes CNAMEs pointing at its infrastructure. Ours are separate records, so add them alongside and leave SendGrid's in place until you have cut over.
- **Point the SDK here, key first** (/docs/quickstart): Set the API key, then the base URL, in that order. Send to yourself and check the X-Message-Id header comes back. Then move a percentage and compare bounce and complaint rates for a week.

When you are ready to stop being compatible, move to /v1. One personalization becomes a flat to, cc, and bcc; the content array becomes html and text; dynamic_template_data becomes template.variables; send_at becomes an RFC 3339 string rather than Unix seconds. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.

## Migrating from Mailgun

https://emails.sh/docs/migrate-mailgun

emails.sh serves a Mailgun-compatible API at https://api.emails.sh/mailgun. It takes their form-encoded send at /v3/<domain>/messages with HTTP basic auth, and answers {"id":"<...@domain>","message":"Queued. Thank you."} with a 200, which is what their schema declares required and what their own SDK fixtures assert. Errors come back as {"message":"..."}, the single field their error schema documents. Their key becomes an esh_ key and goes in the same basic-auth password, with the same api username.

Import your suppression lists before you send anything real, and check your regions. Mailgun keeps bounces, complaints, and unsubscribes per domain, and suppressions are region-bound even though keys and domain names replicate globally. If any of your domains live in the EU region, `npx @emails.sh/cli migrate mailgun --region eu` is a second run you have to do, or every EU bounce is left behind.

The entire change:
```bash
// Node, mailgun.js
const mg = new Mailgun(FormData).client({
  username: 'api',
  key: 'esh_live_yourkey',
  url: 'https://api.emails.sh/mailgun'
});

# PHP
Mailgun::create($key, 'https://api.emails.sh/mailgun');

# Go v5, which rejects a base URL carrying a version
mg.SetAPIBase("https://api.emails.sh/mailgun")

# Go v4, which requires one, and is the only client of the twelve
# in this chapter that reads an environment variable at all
MG_URL=https://api.emails.sh/mailgun/v3
```

### Every language, and how to point it here

These were read from each SDK's source, not from its documentation. All seven are overridable, and only the Go client reads an environment variable, through the explicit NewMailgunFromEnv() entry point rather than ambiently. So this is a code change like Postmark and SendGrid rather than a config change like Resend.

| Language | Package | How to point it at emails.sh | Change |
| --- | --- | --- | --- |
| Node / TypeScript | mailgun.js | client({ username: 'api', key, url: 'https://api.emails.sh/mailgun' }) | client option |
| PHP | mailgun/mailgun-php | Mailgun::create($key, 'https://api.emails.sh/mailgun') | constructor |
| Ruby | mailgun-ruby | Mailgun::Client.new(key, 'api.emails.sh/mailgun') | host, no scheme |
| Python | mailgun | Client(auth=("api", key), api_url="https://api.emails.sh/mailgun") | constructor |
| Java | com.mailgun:mailgun-java | MailgunClient.config("https://api.emails.sh/mailgun", key) | builder |
| Go v5 | mailgun-go/v5 | mg.SetAPIBase("https://api.emails.sh/mailgun") | one line, no /v3 |
| Go v4 | mailgun-go/v4 | MG_URL=https://api.emails.sh/mailgun/v3 | env var, with /v3 |

The two Go rows differ for a real reason: v4 requires the version on the end of the base URL and v5 returns an error if you include it, so a v4 to v5 upgrade during a migration needs both changed at once. Ruby takes a bare host with no scheme, because it builds the URL from a separate secure flag. The Python client refuses a plain http:// host outright unless it is localhost, which is a good rule and not one you will hit here.

### What the compatibility endpoint covers

- `POST /mailgun/v3/:domain/messages` Send, form-encoded. Answers {"id":"<...>","message":"Queued. Thank you."} as theirs does.
- `GET /mailgun/v3/:domain/bounces` Your suppression list, in their {items, paging} envelope.
- `GET /mailgun/v3/:domain/complaints` Spam complaints.
- `GET /mailgun/v3/:domain/unsubscribes` Opt-outs.
- `GET /mailgun/v4/domains` Sending domains, in their {total_count, items} envelope.
- `POST /mailgun/v4/domains` Add one, and get back the DNS records to publish.
- `GET /mailgun/v3/domains` The same list, for clients pinned to the version before they moved it to v4.

A send, unchanged from theirs except the host and the key:
```bash
curl -s --user 'api:esh_live_yourkey'   https://api.emails.sh/mailgun/v3/yourdomain.com/messages   -F from='Acme <receipts@yourdomain.com>'   -F to=someone@example.com   -F subject='Your receipt'   -F text='Thanks for your order.'   -F o:tag=receipts   -F v:order-id=1234
```

These are a translation over the same /v1 handlers everything else uses, not a second implementation. A send through the compatibility endpoint passes the same suppression check, the same quota, the same spend cap, and the same idempotency table as a send through /v1/emails.

### What differs

- **The domain in the URL must match the from address**: At Mailgun the path names your sending domain. Here the path domain and the domain in from must agree, and a mismatch is refused rather than resolved silently in favour of one of them. A request whose URL and body disagree has a bug either way, and a silent success hides it.
- **recipient-variables**: Refused, and this is the field a Mailgun migration most often trips on. Batch sending with per-recipient substitution has a different shape here: POST /v1/emails/batch takes up to a hundred fully rendered messages in one call, each with its own subject and body. Render the per-recipient values in your own code and post the array. That also means each recipient gets their own message rather than seeing the others in the To header.
- **o:testmode**: Refused. There is no accept-and-discard mode, because a message that vanishes cannot be inspected. Send to a reserved test recipient instead: delivered@emails.sh, bounced@emails.sh, complained@emails.sh, or suppressed@emails.sh. Nothing reaches the internet, and you still get a real id, real delivery events and real webhooks, against no allowance. See /docs/sending.
- **Click tracking and the optimisation options**: o:tracking-clicks is refused because we do not rewrite links. o:deliverytime-optimize-period and o:time-zone-localize are refused because we do not model a recipient timezone: compute the moment yourself and pass o:deliverytime, which is honoured up to thirty days out. Simple o:deliverytime works and is parsed from RFC 2822 as theirs is.
- **Tags and variables**: Both translate. o:tag becomes a tag with an empty value, since theirs is a bare label and ours is a pair. v:name=value becomes a tag with its value, since their custom variables are arbitrary key/value metadata echoed on events, which is what our tags are. Both are readable on GET /v1/emails/:id.
- **Reply-To**: Mailgun has no reply_to field: it is set as h:Reply-To, and that is exactly how it arrives here. It is lifted out of the headers and validated as a reply-to rather than passed through as a raw header.
- **Routes**: A Route matches an inbound message and fires an action, and the message is gone unless you stored it. Inbound here is a mailbox: mail to a verified domain is stored, parsed, and threaded against what you sent, and you read and reply through the API. Routes that forward to a human address have no equivalent and should stay at Mailgun. The migrate command does not read them.
- **Regions**: Mailgun runs api.mailgun.net and api.eu.mailgun.net, and messages, logs, suppressions, routes, and mailing lists are region-bound between them. We run one API host, so there is nothing to choose on your side, but a migration has to read both of theirs. Where our own mail leaves from is a separate question: /docs/regions.
- **Message ids**: Theirs is an RFC 2392 message id in angle brackets and ours is a UUID, so we return the UUID as the local part: strip the brackets and the domain and you have the id GET /v1/emails/:id takes.

### Bringing the account across

The code is one line, but an account is also domains, webhooks, stored templates, and three suppression lists per domain per region. One command reads them and imports what it can. It prints a plan and waits for a yes before writing anything, and it is safe to run again.

Import the account:
```bash
npx @emails.sh/cli migrate mailgun
# reads MAILGUN_API_KEY, or pass --from-key
# add --region eu if any of your domains live in Mailgun's EU region:
# suppressions are region-bound there and would otherwise be left behind
# imports bounces, complaints, and unsubscribes for every domain first
```

Their bounce, complaint, and unsubscribe endpoints page on an opaque paging.next URL rather than on an offset, and skip does not work on them, so the command follows the cursor verbatim until a page comes back empty. That matters on a long-lived account: an importer that paged with skip would silently stop after the first hundred.

Suppressions are written straight to your list here before any domain is added, so an interrupted run is one where the half that finished is the half that mattered. A copy is saved as mailgun-suppressions.csv either way.

### Cutting over

- **Import the suppression lists, from every region** (/docs/troubleshooting): Before anything else, and twice if you have EU domains. Run migrate mailgun, then again with --region eu. Everything below this step is reversible; this one is not.
- **Publish our DNS records alongside theirs** (/docs/domains): Add the domain here and publish our DKIM, SPF, and return-path records. Mailgun's can stay while you run both: a domain may carry several DKIM selectors.
- **Point the client here and send one message** (/docs/quickstart): Change the url option, send to yourself, and check the id comes back. Branch on the status and the presence of id rather than on the message string: several near-miss spellings of "Queued. Thank you." exist in the wild.
- **Move sending first, MX last** (/docs/receiving): Send a percentage here and compare bounce and complaint rates for a week. Only change the MX record when you are satisfied, because inbound can point at one provider and that switch is the one that is hard to stage.

When you are ready to stop being compatible, move to /v1. It is a JSON body rather than a form, to is an array rather than a repeated field, h: headers become a headers object, v: variables become tags, and o:deliverytime becomes send_at as an RFC 3339 string. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.
