# API reference

Every endpoint, every operation, and the conventions that hold across all of them.

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

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

### Emails

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

#### `emails.send`

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

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

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

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

#### `emails.batch`

`{ emails: Send[] }`

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

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

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

#### `emails.get`

`{ id: string }`

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

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

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

### Domains

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

#### `domains.list`

`{ }`

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

Returns: { domains: Domain[] }

#### `domains.create`

`{ domain: string }`

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

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

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

#### `domains.get`

`{ id: string }`

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

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

Returns: Domain

#### `domains.verify`

`{ id: string }`

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

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

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

#### `domains.delete`

`{ id: string }`

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

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

Returns: { deleted: id }

### API keys

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

#### `api_keys.list`

`{ }`

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

Returns: { api_keys: ApiKey[] }

#### `api_keys.create`

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

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

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

Returns: { id, name, key }

#### `api_keys.revoke`

`{ id: string }`

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

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

Returns: { deleted: id }

### Webhooks

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

#### `webhooks.list`

`{ }`

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

Returns: { webhooks: Webhook[] }

#### `webhooks.create`

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

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

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

Returns: { id, url, events, secret }

#### `webhooks.get`

`{ id: string }`

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

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

Returns: Webhook

#### `webhooks.update`

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

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

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

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

#### `webhooks.deliveries`

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

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

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

Returns: { deliveries: Delivery[] }

#### `webhooks.delete`

`{ id: string }`

Remove an endpoint. Queued deliveries for it are dropped.

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

Returns: { deleted: id }

### Audiences

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

#### `audiences.list`

`{ }`

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

Returns: { audiences: Audience[] }

#### `audiences.create`

`{ name: string }`

Create an audience.

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

Returns: { id, name }

#### `audiences.members`

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

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

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

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

#### `audiences.import`

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

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

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

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

#### `audiences.updateMember`

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

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

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

Returns: Member

#### `audiences.removeMember`

`{ audience_id: string, member: string }`

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

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

Returns: { deleted: id }

### Broadcasts

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

#### `broadcasts.create`

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

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

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

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

#### `broadcasts.send`

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

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

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

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

#### `broadcasts.test`

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

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

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

Returns: { sent, skipped }

#### `broadcasts.preview`

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

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

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

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

#### `broadcasts.previewContent`

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

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

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

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

#### `broadcasts.recipients`

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

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

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

Returns: { stats, recipients: Recipient[] }

#### `broadcasts.cancel`

`{ id: string }`

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

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

Returns: { id, status: "cancelled" }

### Segments

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

#### `segments.create`

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

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

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

Returns: { id, name, describes, member_count }

#### `segments.get`

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

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

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

Returns: Segment

#### `segments.update`

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

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

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

Returns: Segment

#### `segments.members`

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

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

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

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

### Contacts

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

#### `contacts.tags`

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

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

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

Returns: { tags, added }

#### `contacts.attributes`

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

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

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

Returns: { attributes, changed[] }

#### `contacts.subscriptions`

`{ id: string }`

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

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

Returns: { contact_id, subscriptions[] }

### Templates

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

#### `templates.create`

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

Create a template and its first version.

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

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

#### `templates.addVersion`

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

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

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

Returns: Version

#### `templates.publish`

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

Point live sends at a version.

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

Returns: { published_version_id }

#### `templates.render`

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

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

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

Returns: { subject, html, text, preheader }

#### `templates.preview`

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

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

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

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

### Topics

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

#### `topics.create`

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

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

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

Returns: Topic

#### `topics.preferences`

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

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

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

Returns: { email, preference_url, preferences[] }

### Automations

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

#### `automations.create`

`{ yaml: string }`

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

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

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

#### `automations.pull`

`{ id: string }`

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

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

Returns: text/yaml

#### `automations.push`

`{ id: string, yaml: string }`

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

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

Returns: text/yaml

#### `automations.trigger`

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

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

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

Returns: { run_id, status }

#### `automations.runs`

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

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

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

Returns: { runs: Run[] }

### Analytics

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

#### `analytics.get`

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

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

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

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

#### `analytics.tags`

`{ days?: number }`

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

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

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

### Suppressions

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

#### `suppressions.list`

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

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

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

Returns: { suppressions[], next_cursor, total }

#### `suppressions.create`

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

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

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

Returns: Suppression

#### `suppressions.delete`

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

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

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

Returns: { deleted }

### Dedicated IPs

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

#### `ips.list`

`{ }`

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

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

#### `ips.get`

`{ id: string }`

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

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

Returns: Ip & { reverse_dns }

#### `ips.update`

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

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

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

Returns: Ip

### Received mail

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

#### `messages.list`

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

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

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

Returns: { messages: Message[] }

#### `messages.get`

`{ id: string }`

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

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

Returns: Message

#### `messages.reply`

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

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

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

Returns: { id, status }

#### `threads.get`

`{ id: string }`

Every message in one conversation, oldest first.

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

Returns: Thread

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

---

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.
