# emails.sh > Transactional email for developers, over a REST API, plus the marketing-side > pieces that grew out of it: audiences, segments, broadcasts, templates, > topics, and automations written as YAML. This file is self-contained. > Everything needed to integrate any part of emails.sh correctly is below, and > no other page has to be fetched. Base URL: https://emails.sh/v1 Auth: `Authorization: Bearer esh_...` on every request. Keys are created at https://emails.sh/dashboard/api-keys and shown once. Content type: `application/json` on any request with a body, except the two automation YAML routes, which take and return `application/yaml`. Convention: put the key in `EMAILSSH_API_KEY` in the environment, never in a committed file, and never in client-side code. All sending is server side. ## Send an email `POST /v1/emails` is the endpoint. Everything else in this file is optional. ```bash curl -X POST https://emails.sh/v1/emails \ -H "Authorization: Bearer $EMAILSSH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["ada@example.com"], "subject": "Your receipt from Acme", "html": "

Thanks for your order.

", "text": "Thanks for your order." }' ``` Response, `200 OK`: ```json { "id": "9f2c1b4e-3d5a-4c7e-8b21-0d6f5a3c1e88", "status": "queued" } ``` A send that carries `send_at` answers `202 Accepted` with `{ "id": ..., "status": "scheduled", "scheduled_at": ... }` instead. Those two statuses are the only two: `queued` is 200, `scheduled` is 202. Full request body, with every field: ```json { "from": "Acme ", "to": ["ada@example.com"], "cc": [], "bcc": [], "subject": "Your receipt from Acme", "html": "

Thanks for your order.

", "text": "Thanks for your order.", "reply_to": "support@acme.com", "headers": { "List-Unsubscribe": "" }, "attachments": [ { "filename": "receipt.pdf", "content_type": "application/pdf", "content_base64": "JVBERi0xLjQK" } ], "tags": { "campaign": "receipt", "user_id": "u_8812" }, "template": { "id": "welcome", "variables": { "name": "Ada" } }, "topic": "product-updates", "preheader": "Order 8812, shipped today", "send_at": "2026-08-01T09:00:00Z", "idempotency_key": "order-8812-receipt" } ``` Field rules: - `from` is required, and its domain must be verified on the workspace. Until one is, use `onboarding@emails.sh`, which only reaches addresses belonging to the workspace itself. - `to` is required. Up to 50 addresses counted across `to`, `cc` and `bcc` together. For more recipients, send one email each through `POST /v1/emails/batch`. - `subject` is required. Give `html`, `text`, or both; both is best, because clients and filters both read the text part. A `template` satisfies this. - `attachments` are base64 with no `data:` prefix, 40 MB 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 assembled message shares that same 40 MB, so attachments that each fit can still produce a `413 message_too_large`. - `send_at` is strictly in the future and up to 30 days out. It takes an ISO 8601 timestamp (`2026-08-04T09:00:00Z`), a relative offset (`in 1 min`, `in 2 hours`, `in 3 days`, `in 1 week`), or a clock time (`tomorrow at 9am`, `today at 17:30`, `friday at 3pm`). A clock time with no offset on it is read as **UTC**. Two forms are refused rather than guessed at: a named timezone such as `3pm ET`, because the abbreviation is ambiguous, and `next tuesday`, because it means two different days to two different people. Say `tuesday` for the next one, or send ISO 8601 with the offset in it. The status comes back as `scheduled`, and `POST /v1/emails/:id/cancel` calls it off, as does the older `DELETE /v1/messages/scheduled/:id`. There is no `DELETE /v1/emails/:id`. To move a booking rather than cancel it, `PATCH /v1/emails/:id` with `{"send_at": "tomorrow at 9am"}`: the booking keeps its id, `send_at` is the only field it reads, it is parsed by the same code as the send so the same three refusals apply, and the answer is `200` with `{ id, status: "scheduled", scheduled_at }`. Once the message is on its way it is `409 too_late_to_reschedule`, not a `404`, and it names the status the send reached. Rescheduling takes the `mail:send` scope. - `headers` passes extra fields through verbatim. `In-Reply-To` and `References` are accepted and thread the message: give the parent's Message-ID in angle brackets. When the ids name a message on this workspace the send is filed onto that conversation too, so `GET /v1/threads/:id` returns the whole exchange; when they name a conversation from outside they go out on the wire as given. `Message-ID`, `Date`, `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `MIME-Version`, `Content-Type`, `Content-Transfer-Encoding`, `Return-Path` and the DKIM signature are written by the send and are refused with `400 reserved_header`. `Message-ID` is the id every delivery event, webhook and log row is keyed on, which is why it is ours. Header names are printable ASCII with no spaces and no colon. - `idempotency_key` is your own id for the send, and can also be sent as the `Idempotency-Key` header, which wins over the body field. A repeat within 24 hours returns the first result and sends nothing, with `idempotent-replay: true` on the response. - `tags` are stored with the email, echoed on every webhook for it, and are what `GET /v1/analytics?breakdown=tag` groups by. They are never shown to the recipient. - `template` is `{ "id": "welcome", "variables": { "name": "Ada" } }` and sends a stored template instead of an inline body. `id` takes the slug or the template id. See "Templates" below. - `topic` is a topic key, and files the send under a subscription category the recipient can switch off on its own. See "Topics" below. - `preheader` is the line shown after the subject in the inbox list. It is hidden inside the message itself. ## Endpoints ``` POST /v1/emails send one POST /v1/emails/batch up to 100 in one call, { "emails": [ ... ] } GET /v1/emails the delivery log; limit (25, max 100), cursor POST /v1/emails/:id/cancel call off a send booked with send_at GET /v1/emails/:id status and delivery events for one PATCH /v1/emails/:id { send_at } moves a booking; it keeps its id GET /v1/domains list, 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 POST /v1/domains/:id/verify check DNS now; says which records are missing PATCH /v1/domains/:id { tracking_host } or { tracking_host: null } DELETE /v1/domains/:id remove a domain (?id= is the older spelling) GET /v1/domains/:id/tracking the tracking CNAME to publish, and whether it resolves POST /v1/domains/:id/tracking { host? } checks DNS live and turns it on DELETE /v1/domains/:id/tracking back to the shared host for future sends 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, effective on the next request GET /v1/webhooks endpoints; secrets are not listed POST /v1/webhooks { url, events?, headers? } returns the secret once GET /v1/webhooks/:id one endpoint; the secret is never in a read PATCH /v1/webhooks/:id change one; rotate_secret returns a new secret once DELETE /v1/webhooks/:id remove one (?id= is the older spelling of both) GET /v1/webhooks/deliveries what each attempt got back (status, body, duration, retry) POST /v1/webhooks/deliveries { webhook_id } tests; { delivery_id } replays GET /v1/analytics sent, delivered, bounced, clicked, over a date range GET /v1/analytics/tags which tag keys are worth breaking down by GET /v1/audiences named contact lists POST /v1/audiences { name, description? } GET /v1/audiences/:id one, with its double opt-in setting PATCH /v1/audiences/:id rename, or change confirmation settings DELETE /v1/audiences/:id soft delete; memberships are kept GET /v1/audiences/:id/contacts members; status, limit (100, max 1000), cursor POST /v1/audiences/:id/contacts bulk import, JSON or CSV, with a real dry run GET /v1/audiences/:id/contacts/:member one membership PATCH /v1/audiences/:id/contacts/:member { status?, subscribed?, attributes? } DELETE /v1/audiences/:id/contacts/:member remove the membership POST /v1/audiences/:id/contacts/:member/confirm send the double opt-in email GET /v1/segments saved filters over contacts; ?audience_id= POST /v1/segments { name, audience_id?, match, rules } GET /v1/segments/:id one; ?count=live recomputes member_count PATCH /v1/segments/:id rules are replaced wholesale, never merged DELETE /v1/segments/:id soft delete GET /v1/segments/:id/members limit, cursor; ?mailable=true GET /v1/broadcasts one email to an audience; limit (50, max 100) POST /v1/broadcasts { from, audience_id, subject, html|text|template_id } GET /v1/broadcasts/:id one, with html, text, and problems[] PATCH /v1/broadcasts/:id drafts only DELETE /v1/broadcasts/:id cancel a draft or a scheduled one POST /v1/broadcasts/:id/send now, or { scheduled_at } up to 30 days out POST /v1/broadcasts/:id/cancel same as DELETE, returns { id, status } POST /v1/broadcasts/:id/test { to } up to 5 addresses, moves no counters GET /v1/broadcasts/:id/preview rendered for one member; ?email= POST /v1/broadcasts/preview renders a body you have not saved; writes nothing GET /v1/broadcasts/:id/recipients per-recipient outcome; status, limit, cursor GET /v1/contacts the address book; ?q=, ?lookup=, Accept: text/vcard POST /v1/contacts one contact, or { vcard } for up to 1000 GET /v1/contacts/:id one PATCH /v1/contacts/:id only the keys you send DELETE /v1/contacts/:id hard delete GET /v1/contacts/duplicates pairs sharing a channel or a name POST /v1/contacts/duplicates { survivor_id, loser_id } merges GET /v1/contacts/:id/tags { tags: [] } POST /v1/contacts/:id/tags { tags: [] } or { tag } DELETE /v1/contacts/:id/tags?tag= remove one GET /v1/contacts/:id/attributes workspace-level facts about a person PATCH /v1/contacts/:id/attributes merge patch; null clears one; 100 names per call GET /v1/contacts/:id/subscriptions every list and topic this person is on GET /v1/templates stored templates, with what is published POST /v1/templates { name, slug?, subject?, html?, text?, publish? } GET /v1/templates/:id one template and its versions PATCH /v1/templates/:id rename or re-describe; never changes what sends DELETE /v1/templates/:id soft delete GET /v1/templates/:id/versions every version, and which is published POST /v1/templates/:id/versions { subject, html?, text? } saves a draft, publishes nothing POST /v1/templates/:id/publish { version_id } or { version }; omit both for the latest POST /v1/templates/:id/render { variables } renders strictly, as a send would POST /v1/templates/:id/preview renders with sample values for anything missing GET /v1/topics subscription topics; ?include_archived=true POST /v1/topics { name, key?, default_opt_in?, required? } GET /v1/topics/:id one topic PATCH /v1/topics/:id name, description, default_opt_in, required DELETE /v1/topics/:id archive it, keeping what people said GET /v1/topics/preferences?email= what one address chose, topic by topic POST /v1/topics/preferences { email, topic, subscribed } GET /v1/automations every automation and whether it is enabled POST /v1/automations raw YAML, or JSON { yaml } GET /v1/automations/:id one, with its yaml and its graph PATCH /v1/automations/:id { enabled?, yaml? } DELETE /v1/automations/:id delete it and cancel waiting runs GET /v1/automations/:id.yaml raw YAML, comments and key order preserved PUT /v1/automations/:id.yaml raw YAML in, new version out 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? } GET /v1/automations/:id/runs limit (50, max 200), status GET /v1/automations/:id/runs/:runId one run and every step it executed DELETE /v1/automations/:id/runs/:runId cancel a waiting run GET /v1/suppressions reason, email, limit (50, 1..200), before POST /v1/suppressions { email, reason? } DELETE /v1/suppressions/:id lift one you own (?id= is the older spelling) DELETE /v1/suppressions?email= lift one by address instead of by id GET /v1/ips dedicated addresses, regions, warmup state GET /v1/ips/:id one, plus its reverse DNS check PATCH /v1/ips/:id { paused?, daily_cap? } and nothing else GET /v1/messages received mail (needs an MX record; see below) 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/:name one attachment GET /v1/messages/scheduled sends booked with send_at GET /v1/messages/scheduled/:id one booking DELETE /v1/messages/scheduled/:id cancel a booked send before it leaves GET /v1/threads conversations, newest first GET /v1/threads/:id every message in one conversation GET /v1/search?q= full-text search over received mail ``` OpenAPI 3.1 description: https://emails.sh/openapi.json ## Scopes A key carries zero or more scopes. A key created with none is unrestricted. ``` mail:read GET /v1/emails, /v1/analytics mail:send POST /v1/emails, /v1/emails/batch, all of /v1/broadcasts contacts /v1/contacts, /v1/audiences, /v1/segments workspace /v1/templates, /v1/topics, /v1/automations, /v1/suppressions, /v1/ips identity /v1/identity, the receiving handle ``` Give a production sender `["mail:send"]` and nothing else. A key that can send cannot then delete your audiences. ## Statuses `GET /v1/emails/:id` returns `{ id, status, to, subject, tags, created_at, events[] }`. - `queued` accepted, waiting to go out - `scheduled` booked for a future `send_at`, cancellable - `sent` handed to the receiving mail server and accepted by it - `delivered` the receiving server confirmed it took the message - `bounced` refused; the event carries `bounce_type` (permanent or transient), `bounce_subtype`, and the remote server's diagnostic string - `complained` the recipient marked it as spam; the address is suppressed - `canceled` a scheduled email cancelled before it went Do not poll this endpoint in a loop. Subscribe to webhooks instead. ## Errors Most refusals are `{ "error": { "code": ..., "message": ..., "next": ... } }`. `code` is stable and safe to switch on; `message` says what happened; `next` says what to do about it in prose, and exists because the thing reading it is often a coding assistant. Never match on `message` or `next`. ```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." } } ``` **There is a second shape and a client must parse tolerantly.** These routes answer with a flat body, `{ "error": "topic_key_taken", "message": "...", "hint": "..." }`: `/v1/topics` and `/v1/topics/preferences`, `/v1/suppressions`, all of `/v1/templates`, the `/v1/contacts` collection and item routes, and the rate limiter. Read `error` as either a string or an object, and read the next step from `next`, then `next_step`, then `hint`. | 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. | | `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` value did not parse as an address or as `Name
`. | | `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`. Use `POST /v1/emails/batch`. | | `mixed_test_and_real_recipients` | 400 | The recipients mix a reserved test address with a real one. Nothing was sent to anybody. | | `invalid_template` | 400 | `template` 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** reserved: set those to thread a conversation. | | `invalid_header_name` | 400 | A header name is not a valid token. Names are printable ASCII with no spaces and no colon. | | `invalid_attachment` | 400 | An attachment is missing `filename` or `content_base64`, or the base64 did not decode. | | `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`, 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. | | `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. | | `invalid_from_domain` | 422 | The `from` domain is not verified. Verify it, or send from `onboarding@emails.sh` while testing. | | `domain_not_verified` | 422 | Added but DNS has not resolved. Call `POST /v1/domains/:id/verify` to see which record is 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. 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. | | `idempotency_key_reused` | 409 | The same `idempotency_key` inside 24 hours with a different body. Use a fresh key for a different message. | | `idempotency_in_flight` | 409 | A send with that key is still being processed. Retry the identical request in a second or two. | | `quota_exhausted` | 429 | The monthly or daily send allowance is used up. `next` carries the reset time. Upgrading clears it. | | `spend_cap_reached` | 429 | Metered sending stopped at the workspace spend cap. 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 many distinct recipients today. 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 (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. | Codes specific to one resource are listed with that resource below. Retry rules: never retry a 4xx other than 429 unchanged. On 429, wait `retry-after` seconds. On 5xx or a timeout with no response, retry with backoff and the same `idempotency_key`. ## Domain verification 1. `POST /v1/domains` with `{"domain": "mail.acme.com"}`. Use a subdomain, not the apex domain the customer's human email runs on. 2. The response carries `records`, an array of `{ type, name, value, purpose }`: three `CNAME` DKIM records at `._domainkey.` pointing at `.dkim.amazonses.com`; a `TXT` at `_emailssh.` with `emailssh-verify=`; a `TXT` at the domain with `v=spf1 include:amazonses.com ~all`; a `TXT` at `_dmarc.` with `v=DMARC1; p=none;`; and an optional `MX` at the domain pointing at `10 inbound-smtp.eu-west-1.amazonaws.com`, only needed to receive mail. 3. Publish them at the DNS host. If the domain already has an SPF record, add `include:amazonses.com` to the existing one; two SPF records is a failure, not a merge. 4. `POST /v1/domains/:id/verify`. The response repeats every record with `found: true` or `false`, so a missing one is named rather than guessed at. Verification also runs nightly on its own. 5. `found: false` on a record that was published usually means the DNS host appended the zone to a name that was already absolute. Enter `_emailssh` rather than `_emailssh.mail.acme.com`. 6. A `domain.verified` webhook fires when it completes. Until a domain is verified, send from `onboarding@emails.sh`, which reaches addresses on the workspace only. That is a *sender* address, and mail from it really goes out. To send nothing at all, use the test *recipient* addresses below. ## Test addresses Four reserved recipient 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. | 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 that branch can be exercised without waiting for a real bounce. | - Sub-addressing works on all four: `delivered+run-42@emails.sh`, so a test suite running in parallel can tell its own sends apart in the delivery log. - A message naming 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. - Nothing leaves. A test send never reaches SES. - What it produces is real: 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. Every webhook body carries `"test_mode": true` in `data`, and `GET /v1/emails/:id` returns a `test_mode` boolean on every message. - A test send moves no usage, billing, analytics, bounce-rate or complaint-rate counter, and consumes no monthly allowance or daily cap. It is rate limited like any other request. ## Webhooks `POST /v1/webhooks` with `{ "url": "https://acme.com/hooks/emails", "events": ["email.delivered","email.bounced","email.complained"] }` returns the signing secret once, in that response. Events, and this is the whole list: `email.sent`, `email.delivered`, `email.bounced`, `email.complained`, `email.received` (inbound), `email.filtered` (inbound dropped by a rule before storage), `domain.verified`, `workspace.throttled`, `workspace.paused`, `workspace.resumed`, `automation.run.started`, `automation.run.failed`. There is **no `email.opened`** and no `email.clicked`. An open is a pixel fetch a privacy proxy often makes on the recipient's 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 five non-`email.*` events are account news rather than message news and reach only an endpoint that is not scoped to one mailbox. The old `message.*` spelling is still accepted when you register and every read hands back the `email.*` name. Each delivery is a POST carrying `x-emailssh-event`, `x-emailssh-delivery-id`, and `x-emailssh-signature: t=,v1=`. The digest is HMAC-SHA256 over `"."` with the endpoint secret. Verify against the raw request bytes, not re-serialised JSON. Reject anything older than five minutes. During a rotation the header carries one `v1` per valid secret; accept if any matches. Body: `{ id, event, timestamp, sequence, data }`. On an outbound event `data` carries `email_id`, `to`, `subject`, and the `tags` set on the send. On `email.received` it carries the whole message; see the next section. **The delivery guarantee: at least once, unordered. Deduplicate on `id`, sort by `sequence`.** - *At least 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.* Two events about one message arrive in either order, and routinely do: they are independent queue jobs on independent retry curves, so a `delivered` that succeeded first overtakes a `sent` that needed one retry. `sequence` is the field to sort on. Higher is later: `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. Equal numbers are genuinely unordered and you must not infer an order from them: a bounce and a delivery are exclusive outcomes of the same step. Answer 2xx within ten seconds and do the work afterwards. **Seven attempts, at 0, +5s, +5m, +30m, +2h, +5h and +10h, so 17h 35m in all**, which is the same curve Resend publishes; an endpoint that answers with its own `Retry-After` gets that instead, capped at an hour. After the seventh attempt 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 and a gap in what you received has a row explaining it. `GET /v1/webhooks` publishes the curve as `retry_delays_sec`, `max_attempts` and `retry_window_sec`: read it from there rather than copying these numbers. Turning an endpoint off takes **two conditions at once**: 20 consecutive failed attempts AND 18 hours with nothing accepted, published as `auto_disable_after_silence_sec`. Both, because a bare count 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. A `410 Gone` bypasses both and switches the endpoint off at once, because that is the receiver saying the URL is retired. Events raised while it is off are held for **7 days**, up to 500 of them, oldest dropped past that. A test delivery that lands turns it back on and releases what is held. A failed attempt that still has attempts owed to it carries a `retry` object: `next_attempt`, `next_attempt_at`, `attempts_made`, `attempts_remaining`. It is attached to the **newest** attempt at an event only, and is null on anything that succeeded or has run out of attempts. `next_attempt_at` is **derived from the curve, not stored**: if your endpoint answered 429 with a `Retry-After` we honoured that instead, and the time shown for that event is wrong. Read it as the schedule, not as a promise. `POST /v1/webhooks/deliveries` with `{ "delivery_id": ... }` replays one, which is how you recover from an outage on your side. One endpoint is addressed by its id: `GET /v1/webhooks/:id` reads it, `PATCH /v1/webhooks/:id` changes it in place, `DELETE /v1/webhooks/:id` removes it. The `?id=` query forms of the last two still work and run the same code; the path form is the one to write. Fields left out of a `PATCH` 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. `{"rotate_secret": true}` mints a new signing secret and returns it once, alongside `previous_secret_valid_until`: both secrets sign every delivery until then, so the receiver can be redeployed without refusing valid events. A read never returns the secret at any scope. An id that is not this workspace's answers `404` rather than `403`, and a `PATCH` that changes nothing is `400 nothing_to_update`. ## The inbound webhook payload `email.received` carries the whole message, so a handler never calls back for the body, the sender, or whether the mail is genuine. This is the shape SendGrid's Inbound Parse and Postmark's inbound webhook both hand over, plus `thread_id`, which neither has. ```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.", "html": "

Thanks, but I was charged twice for July.

", "date": "2026-07-28T09:13:58.000Z", "message_id_header": "", "in_reply_to": "", "references": [""], "list_id": null, "headers": { "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 } ] } } ``` - `message_id` is what `GET /v1/messages/:id` and the attachment routes take. `thread_id` is the conversation, stable across the whole exchange; reply in thread with `POST /v1/messages/:id/reply-all`. - `mailbox` is the address of yours it arrived at, which is how you route by product or by tenant. - `text` and `html` are both bodies, whichever the sender provided. Either can be null; a message with only HTML is common. `snippet` is the first line or so. - `date` is the `Date` header as the sender wrote it. It is **not** when we received it: `timestamp` on the envelope is that. - `message_id_header`, `in_reply_to` and `references` are the raw threading chain, for matching against records you already keep. `thread_id` is our answer to the same question and is easier. - `list_id` set means the mail came from a mailing list, which is a strong reason not to auto-reply. - `headers` is every header, keys lowercased, repeats joined with `; `. - **`auth`** is `{ spf, dkim, dmarc }`, each `pass`, `fail` or `none`. Gate on `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`, meaning no policy was published or DNS was briefly unreachable, and treating that as a failure rejects a great deal of real mail. 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. - **`automated`** is 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[].url` is where to fetch the bytes, with your API key. The bytes are not posted to you: a 25MB base64 body is a timeout, not a feature. `inline_images` are images the HTML references with `cid:` rather than files the sender attached, and `content_id` is what the `cid:` matches. ## Pagination **One contract, on every list route.** Send `limit` and `cursor`; the response carries `has_more` and `next_cursor`. ``` GET /v1/?limit=100 -> { "": [...], "has_more": true, "next_cursor": "..." } GET /v1/?limit=100&cursor=... -> { "": [...], "has_more": false, "next_cursor": null } ``` | Field | Meaning | |---|---| | `limit` (request) | Rows per page. Per-route default and cap; over the cap is a 400 naming it, not a silent clamp. | | `cursor` (request) | The previous page's `next_cursor`, sent back unchanged. | | `has_more` (response) | Whether another page exists. A **fact**: every route reads one row more than asked for. | | `next_cursor` (response) | The cursor for the next page, or null. Null exactly when `has_more` is false. | Four rules a client can rely on: 1. **`next_cursor` is opaque.** Some routes happen to use a timestamp, or `,`, or a row id. That is an implementation detail and it will change. Never parse one, never build one, never compare two. 2. **`has_more` is not inferred from a short page.** That inference is wrong on exactly the boundary where the last page is full, which is the case that happens in production and never in a fixture. Do not write it. 3. **Either field is sufficient to stop.** Loop while `has_more`, or loop until `next_cursor` is null. They always agree. 4. **Nothing that worked was removed.** `before` and `after` are still accepted as spellings of `cursor`. `next_after` is still sent beside `next_cursor` by `/v1/audiences/:id/contacts`, `/v1/broadcasts/:id/recipients` and `/v1/segments/:id/members`, and is deprecated. `offset` still works on the two routes that had it, capped at 10,000; past that it is refused with a pointer to `cursor`. An invalid cursor is a `422` everywhere. A few routes are not resumable and say so honestly: they answer `has_more` with `next_cursor: null`, because their order is a ranking (`/v1/contacts` with `q` or `lookup`, `/v1/search`) or a queue rather than a log (`/v1/messages/scheduled`, `/v1/automations/:id/runs`, `/v1/broadcasts`, `/v1/contacts/duplicates`). For those, "see more" means a larger `limit`, and `has_more` says whether it is worth asking. Routes whose collection is returned whole (`/v1/domains`, `/v1/api-keys`, `/v1/webhooks`, `/v1/templates`, `/v1/topics`, `/v1/audiences`, `/v1/segments`, `/v1/automations`, `/v1/mailboxes`, `/v1/ips`, `/v1/analytics/tags`) answer `has_more: false, next_cursor: null`, so a generic pager terminates against them rather than reading `undefined`. One oddity worth knowing: the envelope on `GET /v1/emails` is `data`, not `emails`. Every other list route names its collection. ## Limits - 600 requests a minute per API key, which is 10 a second, 30 a minute per IP without one. The budget is per key rather than per workspace, so a workspace with several keys gets more, never less. Every response from `/v1` and from the four compatibility surfaces carries the budget: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy` (`;w=`), plus the older `X-RateLimit-Limit`, `-Remaining` and `-Reset`. **`RateLimit-Reset` is seconds remaining in the window; `X-RateLimit-Reset` is an absolute unix second.** They mean different things, so do not read one as the other. `429` additionally carries `Retry-After` in seconds; back off on that first and on `RateLimit-Reset` second. **`RateLimit-Remaining` is omitted while the Cloudflare limiter is the one enforcing, because that limiter keeps no live count. An absent header means unknown, not zero.** On a 429 it is always 0. - Free plan: 3,000 emails a month, 100 a day. Paid plans have no daily cap and meter what goes past the monthly allowance. - 50 recipients per message across `to`, `cc` and `bcc`. Bulk email goes through broadcasts, not through a loop over `POST /v1/emails`. - 40 MB of attachments per message, counted after base64 encoding, and 40 MB for the assembled message including headers and both body parts. - 100 emails per `POST /v1/emails/batch` call. - 10,000 contacts per import call, 8 MB of body. - 20 rules per segment. - 80 nodes per automation, 10 sends per automation run, 500 runs an hour. - Message bodies retained 30 days; delivery events 12 months. ## SMTP Any app that already speaks SMTP can send without code changes. The gateway parses the MIME message and calls `POST /v1/emails`, so an SMTP send gets the same domain verification, suppression list, delivery log, webhooks, and quota. ``` host: smtp.emails.sh port: 587 (STARTTLS) or 465 (implicit TLS) username: emailssh <- the literal string, not an email address password: your esh_ API key auth: PLAIN or LOGIN ``` Port 25 is not offered and there is no unauthenticated relay. - The **envelope decides delivery**. Every address the client issues `RCPT TO` for is delivered to; the `To:` and `Cc:` headers only decide labelling. Bcc works as expected. If no envelope address matches any header, every recipient is promoted into `To:` and they become mutually visible. - The **`From:` header** decides which domain must be verified. `MAIL FROM` is only the bounce path. - 40 MB per message, advertised through `SIZE`. 50 recipients per transaction. 10 simultaneous connections per IP. - `X-` headers pass through when their value is a plain string. `X-Emailssh-*` is reserved and dropped. Inline `cid:` attachments are dropped, so a message that references one gets a broken image: send those as ordinary attachments or inline the image as a data URI. - The `250` reply carries the id: `250 2.0.0 Ok: queued as `. That is the id `GET /v1/emails/:id` takes. - Reply codes: `452 4.3.1` quota, `451 4.7.1` rate limited or paused, `451 4.3.0` upstream unreachable, `454 4.7.0` credentials uncheckable, `421 4.7.0` too many failed logins or too many connections, `535 5.7.8` bad credentials, `550 5.7.1` unverified sending domain, `550 5.1.1` suppressed recipient, `550 5.6.0` missing subject or body, `552 5.3.4` over 40 MB. A `4xx` means retry, a `5xx` means never. Full page: https://emails.sh/docs/smtp.md ## Analytics `GET /v1/analytics` answers "how is sending going" over a date range. ```bash curl -sS "https://emails.sh/v1/analytics?from=2026-07-01&to=2026-07-31&group_by=week&breakdown=domain" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" ``` Query: `from` and `to` are `YYYY-MM-DD` in UTC and `to` is inclusive; the default is the last 30 days and the maximum window is 400 days. `group_by` is `day`, `week`, or `month`. `breakdown` is `domain`, `tag`, `mail_class`, or `template`, and `breakdown=tag` also requires `tag_key`. Filters: `domain`, `template_id`, `mail_class` (`transactional` or `marketing`), and `tag` as a single `key:value` string. The response is `{ range, totals, series[], breakdown, notes }`. Every metric object, in `totals`, in each `series` point, and in each breakdown row, has the same fields: ``` sent queued rejected in_flight delivered bounced complained delivered_rate bounce_rate complaint_rate clicks_tracked clicked clicks click_rate opens_tracked opened opens ``` A rate is `null`, not `0`, when its denominator is 0. Report it as "no data", not as zero percent. **There is deliberately no `open_rate`.** Apple Mail Privacy Protection and every other proxying client fetch the pixel whether or not a human looked, and image blocking suppresses it when they did. `opens` and `opened` are here because a trend in them is still worth something; a ratio presented as a rate would be a number people make decisions on, and it is not one. Use `click_rate`. Do not compute an open rate from `opened / delivered`. The breakdown returns the top 20 rows by `sent` and folds the tail into one row whose `key` is `null` and whose `label` is `Other (N)`. `GET /v1/analytics/tags?days=30` lists which tag keys are actually in use, so you can pick one to break down by rather than guessing. Refusals, all 422: `invalid_range`, `range_too_long`, `invalid_group_by`, `invalid_breakdown`, `tag_key_required`, `invalid_mail_class`, `invalid_tag_filter`. Full page: https://emails.sh/docs/analytics.md ## Templates Keep the subject and body on the workspace and send it by name, so changing the wording of a receipt is an API call rather than a deploy. ```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": "

Hello {{ name }}, your plan is {{ plan }}.

", "publish": true }' ``` Then send it: ```bash curl -X POST https://emails.sh/v1/emails \ -H "Authorization: Bearer $EMAILSSH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["ada@example.com"], "template": { "id": "welcome", "variables": { "name": "Ada", "plan": "Pro" } } }' ``` Rules, all of which matter to a program writing this code: - `template.id` takes the slug or the template id. The slug is stable and is what application code should name. - The template supplies `subject`, `html`, and `text`. Anything set alongside it on the request wins, so passing `subject` overrides just the subject. - Substitution is `{{ name }}` and nothing else: the pattern is `{{ identifier }}` with optional dots. No conditionals, no loops, no filters, no expressions. A stored template is never code we execute. - A version is immutable once written, and saving never publishes. Only `POST /v1/templates/:id/publish` changes what live sends render. `PATCH` on a template changes its name, slug and description and never its content. - **A missing variable fails the send**: `422 template_variables_missing`, with a `missing` array naming them, and nothing is sent. Never retry by supplying an empty string; supply the value, or give the variable a `default` in the template's `variables` list, which takes entries as either a bare string or `{ "name": "plan", "default": "Free", "required": false }`. - `POST /v1/templates/:id/render` renders strictly, exactly as a send would, against the published version, and refuses with `422 template_not_published` if there is none. `POST /v1/templates/:id/preview` is lenient: it works on an unpublished draft, fills anything missing with a sample, tells you which ones in `filled_with_samples`, and returns `warnings` about HTML that email clients handle badly (`style_block`, `external_stylesheet`, `flex_or_grid`, `positioning`, `img_without_alt`, `unsupported_element`, `background_image`). Preview never sends anything. - Every sent message records the template id and the version id it rendered from, and `GET /v1/emails/:id` returns both. Template errors use the flat shape: `slug_invalid`, `slug_taken` (409), `name_required`, `variables_invalid`, `nothing_to_update`, `template_not_found`, `version_not_found`, `body_required`. Full page: https://emails.sh/docs/templates.md ## Topics and the preference centre A topic is a named category of mail (product updates, a monthly digest, password resets). Filing a send under one lets a recipient switch that category off without losing their receipts. ```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 }' ``` `key` is lowercase letters, digits and hyphens, at least two characters, and it is **immutable** once created, because unsubscribe links already sitting in people's mail carry it. `PATCH` with a `key` field answers `400 topic_key_immutable`. `DELETE` archives rather than deletes, for the same reason. Add `"topic": "product-updates"` to a send and three things happen: the recipient is checked before anything goes out, the message gets a `List-Unsubscribe` header pointing at a one-click opt-out for that topic alone, and a footer line is added carrying that link and a link to the preference centre. The unsubscribe URL is never rewritten by click tracking, because a one-click unsubscribe behind a redirect breaks RFC 8058. The decision is one rule in a fixed order: 1. A suppression beats everything, including a required topic. That is `422 recipient_suppressed`. 2. A `required` topic cannot be opted out of. Password resets and receipts are not marketing, and the preference page shows them without a switch. Trying to opt out through the API is `409 topic_required_no_opt_out`. 3. A stated preference is obeyed, either way. 4. Silence falls back to the topic's `default_opt_in`. Set it to `false` for anything a regulator would call marketing: that is the opt-in setting. A refused send is `422 topic_opt_out` and nothing is delivered. Preferences are keyed by email address rather than by contact, so a transactional recipient who is on no list still has a working opt-out. Leave `topic` off a genuinely transactional send and it goes out governed by the suppression list alone. `POST /v1/topics/preferences` **without** a `subscribed` field is a read: it answers with the decision for that address and topic without changing anything. With it, it writes. Full page: https://emails.sh/docs/topics.md ## Contacts, tags, and attributes `/v1/contacts` is the address book. A contact has a name, a company, and one or more `channels` (`{ kind: "email" | "phone", value, label? }`). It also carries two kinds of extra data, and confusing them is the most common mistake here: - **Contact attributes**, at `/v1/contacts/:id/attributes`, are workspace-level facts about the person: `plan`, `signed_up_at`, `mrr`. They are stored as text, they are what segments filter on and what automations read as `{{ attributes.plan }}`, and they are the same whichever list the person is on. `PATCH` is a merge patch: send only what changes, send `null` to clear one, up to 100 names per call. The response's `changed` array names only the ones that actually moved. - **Audience merge fields**, the `attributes` object on a *membership* at `/v1/audiences/:id/contacts/:member`, are per-list values substituted into `{{ first_name }}` when a broadcast renders. They are a different store, and writing one does not write the other. **Tags** are at `/v1/contacts/:id/tags`. A tag is 1 to 64 characters, has no comma or line break in it, and is compared case-insensitively and stored lowercased. `POST` takes `{ "tags": ["vip", "beta"] }` or `{ "tag": "vip" }` and answers `{ tags, added }`, where `added` lists only the ones that actually changed. Adding a tag that is already there changes nothing and fires nothing. Adding or removing a tag queues an automation event, which is swept every five minutes. An automation with `trigger: tag.added` therefore starts within about five minutes of the tag, not instantly. `GET /v1/contacts/:id/subscriptions` answers, for every email channel on the contact, whether it is suppressed, which audiences it is on with what status, and every topic with its stated and effective preference. It is the one call for "why is this person not getting our mail". Full page: https://emails.sh/docs/contact-data.md ## Audiences An audience is a named list. `GET /v1/audiences` returns each with `contact_count` and `subscribed_count`. A membership has its own id, and **every member route takes the membership id, not the contact id and not the email address**. Status is one of `subscribed`, `unsubscribed`, `pending` (added under double opt-in and not yet confirmed), or `cleaned` (bounced or suppressed). ### Importing `POST /v1/audiences/:id/contacts` is a bulk import, not a single-contact create. It takes JSON or CSV: ```bash curl -X POST "https://emails.sh/v1/audiences/3c1a7f92-5b8e-4d61-9a03-7e4c2b8d1f60/contacts" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contacts": [ { "email": "ada@example.com", "attributes": { "first_name": "Ada" }, "tags": ["vip"] }, { "email": "grace@example.com", "status": "subscribed" } ] }' ``` The body may also be a bare array, a single object, or `{"data": [...]}`. For CSV, send the file as the body with a `Content-Type` containing `csv`. - `?dry_run=true` is a real dry run: nothing is written, and the answer is `{ dry_run: true, total, would_create, would_update, would_leave_unchanged, would_hold_back, errors[], columns[], sample[] }`, where `columns` says how each CSV header was read and `sample` shows the decision for the first rows. Run this first on anything you did not generate yourself. - Column mapping is query parameters: `mapping.email=Email Address`, `mapping.status=`, `mapping.tags=`, `mapping.attr.=` repeated once per attribute, and `mapping.ignore=` repeated. - 10,000 rows and 8 MB per call: `413 too_many_contacts`, `413 import_too_large`. - An address already on the suppression list is imported as `cleaned`, never as `subscribed`, and counted in `held_back`. The import will not silently resurrect somebody who bounced or complained. - A real import answers `201` with `{ created, updated, unchanged, duplicates, tags_added, held_back, held[], errors[] }`. `created` counts memberships actually written, so it is what the list gained. A row naming somebody already on the list writes nothing and is counted in `duplicates`. ### Double opt-in Off by default. `PATCH /v1/audiences/:id` takes `require_double_opt_in` as three states: `true` on, `false` off, `null` to inherit the workspace default. `GET` reads it back with `double_opt_in_source` saying which one decided. When it is on, adding a member lands them as `pending` rather than `subscribed`, unless the caller explicitly passed `status: "subscribed"`, which exists so a list that was already confirmed elsewhere can be migrated intact. **The confirmation email is not automatic.** Nothing sends it on import, on signup, or on member creation. The only thing that sends it is: ```bash curl -X POST "https://emails.sh/v1/audiences/$AUDIENCE_ID/contacts/$MEMBERSHIP_ID/confirm" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" ``` which answers `202` with `{ membership_id, email, sent, status, confirm_url }`. If you are building a signup flow, you must call it. At most 3 confirmation emails are sent per membership. The link is `https://emails.sh/c/confirm/?e=&t=`, the token is a stateless HMAC, it is valid for 30 days, and a GET on it confirms. Customise the mail with `confirmation_subject` and `confirmation_body` on `PATCH /v1/audiences/:id`; the body substitutes `{{ confirm_url }}`, `{{ audience_name }}`, `{{ ttl_days }}`, and `{{ email }}`. Refusals: `422 confirmation_refused` (the member is not pending, is suppressed, or has had three already), `422 no_sending_address` (no verified sending address on the workspace). Full pages: https://emails.sh/docs/audiences.md and https://emails.sh/docs/double-opt-in.md ## Segments A segment is a saved filter, either over one audience or over the whole address book. `audience_id` decides which. ```json { "name": "Engaged pro trials", "audience_id": "3c1a7f92-5b8e-4d61-9a03-7e4c2b8d1f60", "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": "opened", "op": "within_days", "days": 30 } ] } ``` `match` is `all` (AND) or `any` (OR). There is no nesting and there are at most 20 rules. The rule shapes are exactly these: ``` { field: "tag", op: "has" | "not_has", value } { field: "attribute", op: "eq" | "ne" | "contains" | "starts_with" | "gt" | "lt" | "exists" | "not_exists", name, value } { field: "status", op: "eq" | "ne", value: subscribed|unsubscribed|pending|cleaned } { field: "joined", op: "before" | "after", value: an ISO date } { field: "joined", op: "within_days" | "not_within_days", value: 1 to 3650 } { field: "opened" | "clicked", op: "within_days" | "not_within_days" | "ever" | "never", days } ``` Behaviour worth knowing before you write a filter: - `status` and `joined` rules describe a membership, so they are only legal on a segment that has an `audience_id`. On a workspace-scoped segment they are refused with `400 invalid_request`. - `attribute` with `ne` is **true when the attribute is absent**. If you mean "has a plan and it is not free", add an `exists` rule next to it. - `gt` and `lt` compare numerically only when both sides look like numbers, and compare as text otherwise. Attribute values are stored as text. - `opened` and `clicked` read a cached last-opened and last-clicked timestamp. Open data undercounts, for the reasons in the analytics section, so an `opened: never` segment contains people who did read the mail. `GET /v1/segments/:id?count=live` recomputes `member_count` instead of reading the cached one. `GET /v1/segments/:id/members` takes `limit` and `cursor` like every other list route; see Pagination. `?mailable=true` additionally restricts to `subscribed` members, which is what a broadcast will actually mail. `PATCH` replaces `rules` wholesale. There is no way to add one rule. The response carries `describes`, a one-sentence rendering of the filter, which is worth showing a person before they send to it. Full page: https://emails.sh/docs/segments.md ## Broadcasts A broadcast is one email to an audience. It is the only supported way to send the same message to many people; a loop over `POST /v1/emails` is not, and the `duplicate_content_burst` throttle exists to enforce that. ```bash # 1. Create a draft 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 ", "audience_id": "3c1a7f92-5b8e-4d61-9a03-7e4c2b8d1f60", "segment_id": "5e8b2d31-7a4c-4f19-b063-2c9d8e1a4f77", "topic_id": "product-updates", "subject": "What shipped in July", "html": "

Hello {{ first_name }}, here is what changed.

", "text": "Hello {{ first_name }}, here is what changed.", "track_opens": true, "track_clicks": true }' # 2. Check it renders, against a real member curl -sS "https://emails.sh/v1/broadcasts/$ID/preview?email=ada@example.com" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" # 2b. Or render a body before there is a draft at all. Nothing is written, # and "id" comes back null. With no "audience_id" the recipient is the # placeholder someone@example.com, so every merge field reads # "supplied": false. curl -sS https://emails.sh/v1/broadcasts/preview \ -H "Authorization: Bearer $EMAILSSH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"subject": "What shipped in July", "html": "

Hello {{ first_name }}.

", "from": "Acme ", "audience_id": "3c1a7f92-5b8e-4d61-9a03-7e4c2b8d1f60"}' # 3. Send yourself a test. This moves no counters and writes no recipient rows. curl -X POST "https://emails.sh/v1/broadcasts/$ID/test" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": ["you@acme.com"]}' # 4. Send it, or schedule it curl -X POST "https://emails.sh/v1/broadcasts/$ID/send" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"scheduled_at": "2026-08-01T09:00:00Z"}' ``` - `from` is required at creation and **cannot be changed by PATCH**. The sandbox address is refused: `422 sandbox_not_allowed_for_broadcasts`. - `status` runs `draft`, `scheduled`, `sending`, `sent`, and also `cancelled` and `failed`. Only a `draft` can be edited (`409 broadcast_not_editable`), and only a draft or a scheduled one can be cancelled (`409 broadcast_not_cancellable`). - Creating and reading a broadcast returns `problems`, an array of plain sentences naming what is still missing: an audience, a verified from address, a subject, or a body. `ready` is true when it is empty. Send with something outstanding and you get `422 broadcast_incomplete` with the same sentences joined together. - `scheduled_at` must be strictly in the future and at most 30 days out. - Sending is claimed atomically. A second concurrent send is `409 broadcast_already_sending`. - Sending freezes the content onto the row, so editing the template afterwards does not change what was sent, and expands the recipients. Members the segment excludes are written as `skipped` rows carrying the reason, so "why did Ada not get this" has an answer. - `POST /v1/broadcasts/:id/test` takes up to 5 addresses. - `GET /v1/broadcasts/:id/recipients` pages on `limit` (100, max 500) and `cursor`, filters on `status`, and returns `stats` with the counts and a computed `rates` object alongside. `offset` still works here, capped at 10,000. - `{{ merge_field }}` in the subject or body is filled from the membership's `attributes`. The preview response lists `merge_fields` with `supplied: true` or `false` per field, so you can see who will get a blank before you send. - `POST /v1/broadcasts/preview` is the same render with the content in the request, for a body that has not been saved. It takes `subject`, `html`, `text`, `from`, `reply_to`, `audience_id` and `email`, refuses a request with neither `html` nor `text` (400), answers 404 for an `audience_id` that is not on the workspace, and returns `id: null` and `audience_sample_size: 0` when it rendered for the placeholder. Broadcast stats: `recipients, sent, delivered, bounced, complained, failed, skipped, unique_opens, unique_clicks, total_opens, total_clicks, unsubscribed`. Full page: https://emails.sh/docs/broadcasts.md ## Automations, as YAML An automation is a flowchart stored as a YAML document. It is the most agent-native surface here: read it, edit the text, write it back. ```bash # Pull. Comments and key order survive the round trip. curl -sS "https://emails.sh/v1/automations/$ID.yaml" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" > welcome.yaml # Push. The new version number comes back in a header. curl -X PUT "https://emails.sh/v1/automations/$ID.yaml" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" \ -H "Content-Type: application/yaml" \ --data-binary @welcome.yaml -D - ``` A complete, valid document: ```yaml name: Trial nudges description: Three touches 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 - id: wait_3d wait: 3 days - id: is_pro if: all: - attributes.plan = pro - tags contains active yes: tag_converted no: nudge - id: nudge do: send_template with: template: trial-day-3 topic: product-updates next: [] - id: tag_converted do: add_tag with: tag: converted ``` ### Top-level keys | Key | Required | Default | Notes | | --- | --- | --- | --- | | `name` | yes | | Trimmed to 200 characters. | | `description` | no | null | Trimmed to 1000. | | `trigger` | yes | | One of the list below. | | `when` | no | `{}` | Filters on the trigger. Shape depends on the trigger. | | `reentry` | no | `once` | `once`, `re_enter`, or `always`. | | `enabled` | no | `true` | `enabled: false` is the only way to turn one off in the file. | | `layout` | no | `auto` | `manual` requires `at: [x, y]` on the trigger and every step. | | `entry` | no | first step | A step id, or a list of them. | | `steps` | yes | | At least one. | ### Triggers Events: `contact.subscribed`, `contact.added`, `contact.removed`, `contact.unsubscribed`, `tag.added`, `tag.removed`, `attribute.changed`, `email.delivered`, `email.opened`, `email.clicked`, `email.bounced`, `email.complained`, `email.received`, `broadcast.sent`. Scheduled: `date.attribute`, `schedule.recurring`. Called: `api.call`. `when:` for an event trigger takes any of `audience`, `tag`, `attribute`, `topic`, `template`. Each is an exact, case-insensitive equality filter, and leaving one out matches everything. There is no expression language here. `when:` for `schedule.recurring` takes `hourUtc` (default 9), `weekday` (0 to 6, Sunday is 0), and `dayOfMonth`. It runs at most once an hour. `when:` for `date.attribute` takes `attribute` (required, a contact attribute holding a `YYYY-MM-DD` date), `offsetDays` (default 0, positive is lead time), `hourUtc` (default 9), and `recurring` (compare month and day only, so a birthday). `api.call` automations get a `trigger_url` on `GET /v1/automations/:id`, and are also started by `POST /v1/automations/:id/trigger`, which requires an `idempotency_key` and one of `email` or `contact_id`. Anything under `data` is readable as `{{ trigger. }}`. ### Steps Every step has an `id`, unique within the document and never the word `trigger`, plus exactly one of `if:`, `do:`, or `wait:`. Wiring: a non-branching step takes `next:` as a step id, a list of ids, or `[]` to end that path. Leaving `next:` off falls through to the next step in file order, which is why the example above needs `next: []` on `nudge`. A branching step (`if:`) takes `yes:` and `no:` instead, and using `next:` on one is `invalid_connection`. ### Actions `do: ` with arguments under `with:`. Required arguments are starred. ``` send_template template*, topic, from, replyTo, variables send_audience audience*, template*, topic, from, replyTo add_tag tag* remove_tag tag* add_to_audience audience*, status remove_from_audience audience* set_attribute name*, value unsubscribe audience suppress email, reason call_webhook url*, event, data notify_team to*, subject, body* ``` Any argument value can interpolate `{{ dot.path }}` against the run context. A value that is exactly one variable keeps its type rather than becoming a string. ### Waiting ```yaml - id: a wait: 3 days # minutes, mins, hours, hrs, days - id: b wait: until: "09:00" # next 09:00 UTC - id: c wait: until: monday 09:00 # next Monday 09:00 UTC - id: d wait: for: email.opened # resume early on an event timeout: 3 days # default 7 days ``` A duration is clamped to between one minute and 365 days. After a `for:` wait, `steps..happened` is truthy in the context when the event arrived rather than the timeout. ### Conditions ```yaml - id: check if: any: - attributes.plan != free - tags contains vip - attributes.churned_at is_not_set yes: keep no: drop ``` `all:` is assumed if `any:` is absent. One to ten rules. Each rule is one line, `path op value`. Operators: `=`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `not_contains`, `is_set`, `is_not_set`. The last two take no value. A quoted value is a string, `true` and `false` are booleans, a bare number is a number. String comparison is case-insensitive, and `contains` against a list is membership, not substring. ### Limits and refusals 80 nodes including the trigger, 200 executed nodes in one run, 10 sends per run, 500 runs an hour, one run per contact under `reentry: once`. A bad document is refused with `422` and a message beginning `Line N:`, plus a stable code: `invalid_yaml`, `empty_document`, `missing_name`, `missing_trigger`, `unknown_trigger`, `no_steps`, `invalid_step`, `missing_step_id`, `reserved_step_id`, `duplicate_step_id`, `invalid_connection`, `unknown_step_reference`, `invalid_position`, `invalid_wait`, `invalid_rule`, `unknown_operator`, `invalid_condition`, `invalid_reentry`, `unknown_action`, `unknown_action_arg`, `missing_action_arg`, `invalid_action_args`, `invalid_graph`. Nothing is saved when one fires. Triggering refusals: `409 duplicate` (the idempotency key was seen), `422 automation_disabled`, `422 already_enrolled`, `422 run_in_flight`, `422 concurrency_cap`, `422 hourly_cap`. ### Debugging a run `GET /v1/automations/:id/runs` lists runs with `status` in `running`, `waiting`, `completed`, `stopped`, `failed`, `cancelled`. `GET /v1/automations/:id/runs/:runId` returns every step it executed as `{ nodeId, kind, tool, status, result, error }`, where `kind` is `trigger`, `condition`, `action`, or `wait`, and step `status` is `ok`, `skipped`, `error`, `branch_true`, `branch_false`, `waiting`, or `timed_out`. That is enough to say exactly which branch a contact took and where it stopped. Full page: https://emails.sh/docs/automations.md ## Suppressions An address is suppressed when mail to it hard bounced, was marked as spam, or was unsubscribed. Sending to it is refused with `422 recipient_suppressed`, and that refusal is the feature. ```bash curl -sS "https://emails.sh/v1/suppressions?reason=bounce&limit=50" \ -H "Authorization: Bearer $EMAILSSH_API_KEY" ``` `reason` is `bounce`, `complaint`, `unsub`, or `manual`. `limit` is 1 to 200, default 50. Paging is the one contract: pass the previous page's `next_cursor` back as `cursor`. `email=` looks one address up exactly. `POST` adds one, `{ "email": "...", "reason": "manual" }`, which is how you honour an opt-out that reached you some other way. `DELETE /v1/suppressions/:id` lifts one, taking the `id` off a row in the list, and `DELETE /v1/suppressions?email=ada@example.com` does the same by address when the address is what you have. `?id=` on the collection is the older spelling of the path route and still works. Either way the answer is `{ "deleted": ... }`, and one row goes. Rows with `is_global: true` come from the shared abuse list and appear in every workspace. They cannot be removed and a delete on one answers `404`. `total` counts only this workspace's own rows, so it will be lower than the number of rows returned. Before removing a suppression, know why the address bounced. Sending again to a mailbox that does not exist is what moves a domain's reputation. Full page: https://emails.sh/docs/suppressions.md ## Tracking, and a tracking domain Open and click tracking are **off by default** and are turned on per send (`track_opens`, `track_clicks` on a broadcast) or as a workspace default. With tracking on, links are rewritten to `https://emails.sh/c/` and an invisible pixel is added at `https://emails.sh/o/.gif`. A `List-Unsubscribe` URL is never rewritten. A custom tracking domain puts those URLs on your own hostname, which is worth doing: a link that does not match the sending domain is a mild spam signal, and a shared tracking host carries other people's reputation. - Publish one CNAME: `click.acme.com` pointing at `track.emails.sh`. - The host must be under a custom sending domain you have already verified. A shared sending subdomain is not yours to brand, and the route refuses it with `422 tracking_needs_custom_domain`. - There are three routes, so an assistant that just verified a sending domain can finish the job without anybody opening a settings page: ``` GET /v1/domains/:id/tracking the record to publish, and whether it resolves POST /v1/domains/:id/tracking { host? } checks DNS live and turns it on DELETE /v1/domains/:id/tracking back to the shared host for future sends ``` - `PATCH /v1/domains/:id` is the same pair in one field: `{"tracking_host": "click.acme.com"}` sets it and `{"tracking_host": null}` clears it, with the same live CNAME check and the same refusals. A bare label is expanded under the domain, so `"click"` on `mail.acme.com` means `click.mail.acme.com`. `tracking_host` is the only setting a domain has: `open_tracking` and `click_tracking` are refused by name with `422 tracking_switch_not_supported` rather than accepted and dropped, because there is no such switch here and a control that reports success while changing nothing is worse than none. - The host defaults to `click.`. `POST` checks the CNAME live and answers `422 tracking_cname_not_found` until it resolves, rather than rewriting every link in your next send to a hostname that answers nothing. DNS takes a few minutes to become visible and until then your links keep pointing at emails.sh, which is not a failure. - Links already sent resolve by link id, so changing or removing the host never breaks mail that is already out. It does mean the CNAME has to stay published for those links to keep resolving, and the `DELETE` response says so in `note`. Full page: https://emails.sh/docs/tracking-domain.md ## Dedicated IPs and regions `GET /v1/ips` lists any dedicated addresses on the workspace, the shared pools (one transactional, one marketing), the default region, and a `residency` statement. An IP row carries `ip`, `region`, `region_label`, `mail_class`, `pool_name`, `warmup_state`, `warmup_day`, `daily_cap`, `sent_today`, and the warmup timestamps. `mail_class` is derived from the pool name and is `transactional`, `marketing`, or null. `warmup_state` is `pending`, `warming`, `ready`, `paused`, or `retired`. Warmup is a fixed 17-day schedule of daily caps: 50, 100, 500, 1000, 5000, 10000, 20000, 40000, 70000, 100000, 150000, 200000, 300000, 400000, 500000, 750000, 1000000. `daily_cap` is the lower of the schedule and any operator cap, and is null once the address is `ready`. `POST /v1/ips` takes one out of inventory: `{ "mail_class": "transactional" | "marketing", "region"? }`, answering `201` with the new address. The class travels with the address for life and is not optional in spirit: 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, so `daily_cap` on the response is small on day one. 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 caller error. `DELETE /v1/ips/:id` hands the address back. Sends fall back to the shared pool for their class immediately, so this does not stop mail, but the warmup it had accumulated stops being yours. Pause while a reputation problem is being investigated; release when you are finished with it. `PATCH /v1/ips/:id` takes exactly two fields, `paused` and `daily_cap`. Nothing can raise the warmup allowance; a `daily_cap` only lowers it. `GET /v1/ips/:id` also returns `reverse_dns` with `expected`, `found`, `forwardConfirmed`, and `ok`. **Regions**: there are two, `us` and `eu`, and the default is `eu`. The region is resolved from the domain first, then the workspace, then the default. The only thing it changes is which sending endpoint the mail leaves from. Storage is in the EU either way, and the `residency` object on `GET /v1/ips` says so. It is **not selectable through the API or the dashboard today**: it reads back and nothing writes it. Do not offer a user a region switch. Full pages: https://emails.sh/docs/dedicated-ips.md and https://emails.sh/docs/regions.md ## Moving from another provider Two paths, and the first one is usually right. ### Wire-compatible endpoints Point the SDK you already use at us and change nothing else. Each of these translates and calls `POST /v1/emails`, so the delivery log, suppression list and quota are identical whichever door the mail comes through. | Provider | Base URL | Auth | | --- | --- | --- | | Resend | `https://api.emails.sh/resend` | `Authorization: Bearer esh_...`, unchanged | | Postmark | `https://api.emails.sh/postmark` | `X-Postmark-Server-Token`, `X-Postmark-Account-Token`, or a bearer token | | SendGrid | `https://api.emails.sh/sendgrid/` | `Authorization: Bearer esh_...`, unchanged | | Mailgun | `https://api.emails.sh/mailgun` | HTTP basic; the username is ignored, the password is the key | What each supports: - **Resend**: emails, batch, get one, cancel, domains, api-keys. `scheduled_at` becomes `send_at`, `tags: [{name, value}]` becomes our map, and `attachments[].content` becomes `content_base64`. An attachment given as a `path` is fetched server side over https only, with no redirects, up to 512 KB. `POST` answers `{"id": "..."}` and nothing else. Audiences, contacts, broadcasts, segments, topics, template management, automations, webhooks and logs answer `422` with a sentence saying which emails.sh endpoint to use. - **Postmark**: `email`, `email/batch`, `email/withTemplate`, `email/batchWithTemplates`, domains, bounces, templates, webhooks, message-streams. A `MessageStream` other than `outbound` is refused with error 1236, `TrackLinks` other than `"None"` with 403, and an attachment `ContentID` is refused rather than dropped. `TrackOpens` and `InlineCss` are accepted and have no effect. **postmark.js and postmark-java cannot be pointed here**: they take a bare hostname with no path prefix. Use the REST API or SMTP from those two. - **SendGrid**: `v3/mail/send`, templates, whitelabel domains, and the five suppression lists. `personalizations` fan out into a batch, up to 100. Success is `202` with an **empty body** and the id in the `X-Message-Id` header. In the Node library, call `setApiKey` **before** `setDefaultRequest('baseUrl', ...)`, or it silently reverts to api.sendgrid.com. Refused rather than silently ignored: `batch_id`, `asm.group_id`, `ip_pool_name`, `sandbox_mode`, every `bypass_*_management`, `tracking_settings.click_tracking`, `subscription_tracking`, `ganalytics`. - **Mailgun**: form-encoded `v3//messages`, the three suppression lists, and domains. Success is `{"id": "", "message": "Queued. Thank you."}`. Errors carry only `{"message": "..."}`, because that is all their schema has. Refused: `o:testmode`, `o:tracking-clicks`, `amp-html`, `recipient-variables`, `t:version`, and inline files. mailgun-go v4 needs `/v3` appended to the URL; v5 rejects it. A compatibility endpoint is a migration aid, not the destination. Move to `POST /v1/emails` when you next touch that code: templates, topics, broadcasts and automations are not reachable through any of these four doors. ### SMTP If the app speaks SMTP, change the host, port and credentials as in the SMTP section above and nothing else. This is the shortest path off Postmark, SendGrid, Mailgun, SES, or a Gmail relay. Full pages: https://emails.sh/docs/migrate-resend.md, /docs/migrate-postmark.md, /docs/migrate-sendgrid.md, /docs/migrate-mailgun.md ## Clients - TypeScript / Node: `npm install @emails.sh/sdk`, then `const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY })` and `await mail.send({ from, to, subject, html })`. Node 18 or newer. Namespaced groups hang off the client: `mail.domains.*`, `mail.webhooks.*`, `mail.apiKeys.*`, `mail.audiences.*`, `mail.segments.*`, `mail.broadcasts.*`, `mail.templates.*`, `mail.topics.*`, `mail.automations.*`, `mail.suppressions.*`, `mail.analytics.*`. Plain `fetch` works just as well, with no dependency. - Python: `pip install emailssh`, then `mail = Emailssh(api_key=os.environ["EMAILSSH_API_KEY"])` and `mail.send(from_=..., to=[...], subject=..., html=...)`. The argument is `from_` because `from` is a Python keyword; it goes over the wire as `from`. - CLI: `npm install -g @emails.sh/cli`, then `emails send --to ... --subject ...`. Also `emails broadcasts`, `emails audiences`, `emails segments`, `emails templates`, `emails automations pull/push`, `emails analytics`, and `emails suppressions`. Every command takes `--json`. - Every other language: one HTTPS POST with a bearer token. Complete programs for each are at https://emails.sh/docs/php.md, /docs/ruby.md, /docs/go.md, /docs/rust.md, /docs/java.md, /docs/dotnet.md, /docs/elixir.md. ## Integrating this into a codebase If you are an assistant adding emails.sh to a project, do this: 1. Detect the framework from the manifest (`package.json`, `composer.json`, `Gemfile`, `requirements.txt`, `pyproject.toml`, `go.mod`, `mix.exs`). 2. Install the client for that stack, or use the built-in HTTP client. 3. Put `EMAILSSH_API_KEY` in the env file that framework reads (`.env.local` for Next.js, `.env` for most others, `.dev.vars` and `wrangler secret put` for Cloudflare Workers, `supabase secrets set` for Supabase Edge Functions, Rails credentials for Rails). Check the file is gitignored first. Ask the user for the key; it comes from https://emails.sh/dashboard/api-keys. 4. Write the send on the server only. A key in client-side code is public. 5. Use the framework's own mail layer where it has one: a Laravel transport, a Rails ActionMailer delivery method, a Django EMAIL_BACKEND, a Swoosh adapter in Phoenix. Everything already written keeps working. 6. Tell the user which DNS records to publish, from the `POST /v1/domains` response, and that they can send from `onboarding@emails.sh` meanwhile. 7. Never send email to a real person without asking first. A test to the user's own address is fine to offer. Framework guides, each also available with `.md` on the end: /docs/nextjs, /docs/nuxt, /docs/sveltekit, /docs/remix, /docs/astro, /docs/express, /docs/hono, /docs/cloudflare-workers, /docs/vercel, /docs/supabase, /docs/laravel, /docs/rails, /docs/django, /docs/flask, /docs/fastapi, /docs/spring, /docs/phoenix Two agent surfaces, and they pair: - The **skill** is knowledge. `npx skills add emailssh/skill` installs the instructions for integrating emails.sh into a codebase, wiring a webhook, building an audience and sending a broadcast, writing an automation, and migrating off another provider. - The **MCP server** is the ability to act on the account itself. Connect it at `https://mcp.emails.sh` (Streamable HTTP, `Authorization: Bearer esh_...`), or `claude mcp add --transport http emailssh https://mcp.emails.sh`. It covers domains and their DNS rows, keys, webhook deliveries and replays, bounce lookups and suppressions, broadcasts and audiences and segments, templates, automations including reading and writing the YAML, and analytics. It is a thin client over the endpoints above, so anything it does you can do with curl. Full page: https://emails.sh/docs/mcp.md. ## Deliverability, briefly - Send from a subdomain (`mail.acme.com`), not the apex domain a company's human email uses. Reputation stays separate and an MX record does not clash. - Publish DKIM, SPF, and DMARC. Start DMARC at `p=none` and tighten it after a week of clean reports. - Always include a `text` part. An HTML-only message is a filter signal. - Warm up: raise volume over days rather than sending a new domain's first hundred thousand messages on day one. The dedicated IP warmup schedule above is a reasonable shape for a domain too. - Put `List-Unsubscribe` and `List-Unsubscribe-Post` on anything a person might want to stop. Sending with a `topic` does this for you. Gmail and Yahoo require one-click unsubscribe on bulk mail. - Never send to an address that hard bounced. It is suppressed for you, and the refusal is the feature. ## Receiving Publishing the `MX` record on a verified domain turns on inbound. Mail to any address on that domain is stored and available at `GET /v1/messages`, and an `email.received` webhook fires as it lands. Replies are threaded, and `POST /v1/messages/:id/reply-all` sets `In-Reply-To` and `References` for you. There is no IMAP and no webmail: it is an API surface. ## Documentation - https://emails.sh/llms.txt (this file) - https://emails.sh/docs.md (every page as one markdown document) - https://emails.sh/docs/.md (any single page as markdown) - https://emails.sh/openapi.json (OpenAPI 3.1) - Pages: /docs/quickstart, /docs/api-keys, /docs/domains, /docs/sending, /docs/batch, /docs/delivery, /docs/receiving, /docs/agents, /docs/rest, /docs/errors, /docs/webhooks, /docs/audiences, /docs/segments, /docs/broadcasts, /docs/contact-data, /docs/double-opt-in, /docs/templates, /docs/topics, /docs/automations, /docs/analytics, /docs/suppressions, /docs/tracking-domain, /docs/dedicated-ips, /docs/regions, /docs/smtp, /docs/mcp, /docs/troubleshooting, /docs/glossary - Languages: /docs/curl, /docs/node, /docs/python, /docs/php, /docs/ruby, /docs/go, /docs/rust, /docs/java, /docs/dotnet, /docs/elixir - /vs/.md compares emails.sh with resend, postmark, sendgrid, mailgun, ses, loops, brevo, and mailersend - /with/.md covers a platform integration, /for/.md an audience, /templates/.md a runnable transactional template - https://emails.sh/pricing.md (plans, limits, and overage rates) ## Legal and contact - Operated by CROapps Oy, Helsinki, Finland. Business ID 3550932-5, VAT FI35509325. - /legal/terms, /legal/privacy, /legal/acceptable-use, /legal/dpa, /legal/subprocessors, /legal/cookies, /legal/refunds - Support: https://emails.sh/contact. Quote the `x-request-id` from the response and the email id. - We never read message content and never train on it. Abuse enforcement is based on sending behaviour and metadata only.