# Send an email

POST /v1/emails in full: every field, attachments, scheduling, tags, and idempotency.

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.

---

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Whole API in one file: https://emails.sh/llms.txt. All documentation: https://emails.sh/docs.md.
