{"openapi":"3.1.0","info":{"title":"emails.sh API","version":"1.0.0","description":"Transactional email for developers. POST /v1/emails sends one email and is the endpoint most integrations only ever use; the rest of this description covers domains, keys, webhooks, and the inbound side. Auth is a bearer API key from https://emails.sh/dashboard/api-keys. Every refusal carries a stable code and a sentence saying what to do about it.","contact":{"name":"emails.sh support","email":"support@emails.sh"}},"servers":[{"url":"https://emails.sh"}],"x-rate-limit":{"description":"600 requests per minute per API key, which is 10 per second, and 30 per 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 in two spellings: the IETF draft names RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy, and the older X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. RateLimit-Reset is SECONDS REMAINING in the window; X-RateLimit-Reset is an absolute unix second. They mean different things and must not be read interchangeably. RateLimit-Remaining is omitted when 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 and Retry-After carries the wait in seconds. Back off on Retry-After first and on RateLimit-Reset second.","perKeyPerMinute":300,"anonymousPerMinutePerIp":30,"headers":{"RateLimit-Limit":"Requests allowed in the window.","RateLimit-Remaining":"Left in it. Omitted when the enforcing limiter keeps no live count; always 0 on a 429.","RateLimit-Reset":"Seconds remaining in the window.","RateLimit-Policy":"<limit>;w=<seconds>","Retry-After":"Seconds. On a 429 only."}},"x-pagination":{"description":"One contract on every list route. Send limit and cursor; the response carries has_more and next_cursor. has_more is a fact rather than an inference: every route reads one row more than it was asked for and reports whether it got it. Never infer \"no more pages\" from a short page, which is wrong on exactly the boundary where the last page is full. next_cursor is opaque: some routes happen to use a timestamp, or <timestamp>,<uuid>, or a row id, and that will change, so never parse one, never build one, never compare two. next_cursor is null exactly when has_more is false, so either field is sufficient to stop. Nothing that worked was removed: before and after are still accepted as spellings of cursor, next_after is still sent beside next_cursor by the audience-contacts, broadcast-recipients and segment-members routes and is deprecated, and offset still works on the two routes that had it, capped at 10000, past which 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 by answering has_more with next_cursor null, because their order is a ranking or a queue rather than a log; for those, \"see more\" means a larger limit. Routes whose collection is returned whole answer has_more false and next_cursor null, so a generic pager terminates against them rather than reading undefined."},"security":[{"apiKey":[]}],"tags":[{"name":"Emails","description":"Sending, batching, and the status of what you sent"},{"name":"Domains","description":"Sending domains and the DNS records they need"},{"name":"API keys","description":"Creating and revoking credentials"},{"name":"Webhooks","description":"Signed delivery of delivery and inbound events"},{"name":"Audiences","description":"Named contact lists and their subscription state"},{"name":"Messages","description":"Received mail: reading, replying, forwarding, attachments"},{"name":"Threads","description":"Whole conversations"},{"name":"Suppressions","description":"Addresses this workspace will not send to, and why"},{"name":"Contacts","description":"People at the workspace level, with their tags and attributes"},{"name":"Segments","description":"Saved filters over contacts, used to narrow a broadcast"},{"name":"Broadcasts","description":"One send to a whole audience, and what happened to it"},{"name":"Templates","description":"Versioned email bodies with {{ name }} variables"},{"name":"Topics","description":"Subjects a recipient can opt out of one at a time"},{"name":"Automations","description":"YAML flows that run steps for one contact at a time"},{"name":"Analytics","description":"What was sent and what happened to it, over time"},{"name":"Dedicated IPs","description":"Dedicated sending addresses and their warmup"},{"name":"Identity","description":"The receiving handle this key is bound to, and its mail rules"},{"name":"Mailboxes","description":"The inboxes in the workspace, and creating another"}],"paths":{"/v1/emails":{"post":{"tags":["Emails"],"summary":"Send an email","description":"The canonical endpoint. Returns as soon as the email is accepted and queued; delivery happens after that, and GET /v1/emails/{id} or a webhook is how you learn the outcome. To exercise an integration without putting mail on the internet, send to one of the four reserved test addresses: delivered@emails.sh, bounced@emails.sh, complained@emails.sh, or suppressed@emails.sh. Each produces a real message id, real delivery events and real webhooks (every body carries \"test_mode\": true in data), never reaches SES, and moves no usage, billing or reputation counter. Sub-addressing works, so delivered+run-42@emails.sh is distinguishable in the log. Mixing a test address with a real one in the same message is refused with 400 mixed_test_and_real_recipients and nothing is sent to anybody.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendEmail"}}}},"responses":{"200":{"description":"Accepted and queued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Queued"}}}},"202":{"description":"Booked for a future send_at. Cancel it at DELETE /v1/messages/scheduled/{id}.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Queued"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Emails"],"summary":"The delivery log","description":"What this workspace has sent, newest first. The same list the dashboard shows, because \"did it arrive\" is the question this product exists to answer and clicking to find out is the thing being replaced.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100},"description":"Defaults to 25, capped at 100."}],"responses":{"200":{"description":"The most recent outbound messages","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Email"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/emails/batch":{"post":{"tags":["Emails"],"summary":"Send up to 100 emails in one request","description":"Each entry is its own email to its own recipient, and each succeeds or fails on its own. The response array is in the order you sent it, one entry per input.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["emails"],"properties":{"emails":{"type":"array","minItems":1,"maxItems":100,"items":{"$ref":"#/components/schemas/SendEmail"}}}}}}},"responses":{"207":{"description":"One result per entry, in order","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/Queued"},{"$ref":"#/components/schemas/Error"}]}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/emails/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Emails"],"summary":"Status and delivery events for one email","description":"The answer to \"did it arrive\". Do not poll it in a loop: subscribe to webhooks and let the events come to you.","responses":{"200":{"description":"The email and its timeline","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Email"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Emails"],"summary":"Move a scheduled send to a new time","description":"Changes when a send booked with send_at goes out, keeping the same id. send_at is the only field this reads, and it is the same name and the same parse POST /v1/emails uses, so a time this refuses is a time the send would have refused. Cancel and rebook was the only way to do this before, and it hands back a different id, so a caller who stored the first one gets a 404 on their next cancel. To call the send off entirely, POST /v1/emails/{id}/cancel.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["send_at"],"properties":{"send_at":{"type":"string","example":"2026-08-05T09:00:00Z","description":"ISO 8601, or a relative phrase such as \"in 2 hours\". In the future, and no more than 30 days out."}}}}}},"responses":{"200":{"description":"Moved. Same id, new time.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["scheduled"]},"scheduled_at":{"type":"string","format":"date-time"}}}}}},"400":{"description":"send_at missing, unparseable, in the past, or further out than the schedule window allows.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No scheduled email with that id on this workspace. Only a send made with send_at can be rescheduled, and only before it goes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"too_late_to_reschedule. The booking is real and the request was reasonable; the message is already on its way. 409 rather than 404, for the same reason the cancel answers 409: a caller told \"not found\" goes looking for a bug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/emails/{id}/cancel":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Emails"],"summary":"Cancel a scheduled send","description":"Calls off a send booked with send_at, if it has not gone yet. The id is the one POST /v1/emails returned. Scoped to the workspace rather than to one sending address, because a transactional caller sends from receipts@ one moment and noreply@ the next. DELETE /v1/messages/scheduled/{id} still works and does the same thing.","responses":{"200":{"description":"Cancelled. Nothing was sent.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["cancelled"]}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"too_late_to_cancel. The booking is real and the request was reasonable; the message is already on its way. 409 rather than 404, because a caller told \"not found\" goes looking for a bug and one told \"too late\" sends a correction.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/api-keys":{"get":{"tags":["API keys"],"summary":"List keys","description":"Values are never listed. A key is shown once, in the response that created it.","responses":{"200":{"description":"Keys, with the last time each was used","content":{"application/json":{"schema":{"type":"object","properties":{"api_keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["API keys"],"summary":"Create a key","description":"The value is in this response and nowhere else afterwards. Omitting scopes gives full access.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"What it is for, so a later reader can revoke the right one."},"scopes":{"type":"array","items":{"type":"string","enum":["mail:send","mail:read","workspace"]}}}}}}},"responses":{"201":{"description":"Created, with the key","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiKey"},{"type":"object","properties":{"key":{"type":"string","examples":["esh_..."]}}}]}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/api-keys/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"delete":{"tags":["API keys"],"summary":"Revoke a key","description":"Effective on the next request, with no grace period. Deploy the replacement first.","responses":{"200":{"description":"Revoked","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/audiences":{"get":{"tags":["Audiences"],"summary":"List audiences","responses":{"200":{"description":"Audiences","content":{"application/json":{"schema":{"type":"object","properties":{"audiences":{"type":"array","items":{"$ref":"#/components/schemas/Audience"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Audiences"],"summary":"Create an audience","description":"A name that is already taken answers 409 audience_exists rather than making a second list with the same name.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","examples":["Product updates"]},"description":{"type":"string"}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Audience"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/audiences/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Audiences"],"summary":"Read one audience","description":"Adds the double opt-in setting that applies to this list. double_opt_in_source says where the answer came from: audience when this list overrides, workspace when it inherits.","responses":{"200":{"description":"The audience","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Audience"},{"type":"object","properties":{"require_double_opt_in":{"type":"boolean"},"double_opt_in_source":{"type":"string","enum":["audience","workspace"]}}}]}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Audiences"],"summary":"Update an audience","description":"require_double_opt_in is three-valued: true requires confirmation on this list, false skips it, null goes back to the workspace default. The confirmation copy is used by POST /v1/audiences/{id}/contacts/{member}/confirm.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"require_double_opt_in":{"type":["boolean","null"]},"confirmation_subject":{"type":"string"},"confirmation_body":{"type":"string","description":"Supports {{ confirm_url }}, {{ audience_name }}, {{ ttl_days }} and {{ email }}."}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Audience"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Audiences"],"summary":"Delete an audience","description":"A soft delete: the memberships stay on record so a later import cannot resurrect an unsubscribe.","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"deleted":{"type":"boolean"}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/audiences/{id}/contacts":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Audiences"],"summary":"List members of an audience","description":"Each row is a membership, not a contact. Its id is the membership id, which is what the {member} routes take; contact_id is the workspace-level contact behind it.","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["subscribed","unsubscribed","pending","cleaned"]}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":1000,"default":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Members","content":{"application/json":{"schema":{"type":"object","properties":{"contact_count":{"type":"integer"},"subscribed_count":{"type":"integer"},"contacts":{"type":"array","items":{"$ref":"#/components/schemas/AudienceContact"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Audiences"],"summary":"Import contacts into an audience","description":"A bulk import, not a single-contact create. Post JSON (a bare array, {\"contacts\":[...]}, {\"data\":[...]}, or one object) or a CSV body when the content type contains csv. Each row needs email (email_address is accepted) and may carry attributes (or data), status or subscribed, and tags as an array or a comma-separated string. Any other CSV column becomes a merge attribute you can use as {{ first_name }} in a broadcast. Re-posting an address updates the membership instead of duplicating it, and an address on the suppression list lands as cleaned rather than subscribed and is counted in held_back. Limits are 8 MB of body (413 import_too_large) and 10000 rows (413 too_many_contacts).","parameters":[{"name":"dry_run","in":"query","schema":{"type":"boolean"},"description":"Parse and report what would happen without writing anything. Also accepted as dry_run in the JSON envelope."},{"name":"mapping.email","in":"query","schema":{"type":"string"},"description":"Column holding the address, for a CSV whose header is not email."},{"name":"mapping.status","in":"query","schema":{"type":"string"}},{"name":"mapping.tags","in":"query","schema":{"type":"string"}},{"name":"mapping.attr.<column>","in":"query","schema":{"type":"string"},"description":"Rename one column to a merge attribute, for example mapping.attr.First%20Name=first_name. Repeatable."},{"name":"mapping.ignore","in":"query","schema":{"type":"string"},"description":"Drop a column entirely. Repeatable."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactImport"}},"text/csv":{"schema":{"type":"string","description":"A header row plus one row per contact."}}}},"responses":{"200":{"description":"Dry run, nothing written","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportDryRun"}}}},"201":{"description":"Imported","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportResult"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/audiences/{id}/contacts/{member}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"member","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The membership id, the id field of a row from GET /v1/audiences/{id}/contacts. It is not the contact id and it is not the email address."}],"get":{"tags":["Audiences"],"summary":"Read one membership","responses":{"200":{"description":"The membership","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AudienceContact"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Audiences"],"summary":"Update one membership","description":"attributes are the per-list merge fields for this address. subscribed is a shortcut for the two common statuses and wins over status when both are sent.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"attributes":{"type":"object","additionalProperties":{"type":"string"}},"subscribed":{"type":"boolean"},"status":{"type":"string","enum":["subscribed","unsubscribed","pending","cleaned"]}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AudienceContact"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Audiences"],"summary":"Remove a membership","description":"Takes the address off this list. To record an unsubscribe, PATCH the status to unsubscribed instead, so a later import cannot bring it back.","responses":{"200":{"description":"Removed","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/audiences/{id}/contacts/{member}/confirm":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"member","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Audiences"],"summary":"Send the double opt-in confirmation","description":"The only thing that sends a confirmation email. Nothing sends it for you when a pending member is created. The link is a stateless HMAC good for 30 days, and a membership accepts at most 3 sends.","responses":{"202":{"description":"Confirmation sent","content":{"application/json":{"schema":{"type":"object","properties":{"membership_id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"sent":{"type":"boolean"},"status":{"type":"string"},"confirm_url":{"type":"string","format":"uri"}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/messages":{"description":"Received mail. Available on a verified domain with an MX record published; see https://emails.sh/docs/receiving. For sending, use POST /v1/emails.","get":{"tags":["Messages"],"summary":"List received messages","parameters":[{"name":"unread_only","in":"query","schema":{"type":"boolean"}},{"name":"thread_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"include_archived","in":"query","schema":{"type":"boolean","default":false},"description":"Archived mail is excluded by default"},{"name":"limit","in":"query","schema":{"type":"integer","maximum":100,"default":20}}],"responses":{"200":{"description":"Messages, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Messages"],"summary":"Reply to received mail","description":"The inbound side of the API. Passing reply_to_message_id threads the send onto a conversation that arrived here. New transactional mail goes through POST /v1/emails instead; this endpoint exists because a reply needs the message it is answering.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["to","subject","body"],"properties":{"to":{"type":"array","items":{"type":"string","format":"email"},"minItems":1,"maxItems":50},"cc":{"type":"array","items":{"type":"string","format":"email"},"maxItems":50},"subject":{"type":"string"},"body":{"type":"string","description":"Markdown or plain text"},"reply_to_message_id":{"type":"string","format":"uuid"},"attachments":{"type":"array","items":{"type":"object","required":["filename","content_base64"],"properties":{"filename":{"type":"string"},"content_type":{"type":"string","default":"application/octet-stream"},"content_base64":{"type":"string","description":"base64 bytes, no data: prefix"}}},"maxItems":10},"track_opens":{"type":"boolean"},"send_at":{"type":"string","description":"Send it then rather than now, up to 30 days out. Takes an ISO 8601 timestamp, a relative offset such as \"in 1 min\", or a clock time such as \"tomorrow at 9am\", which is read as UTC. Answers 202 with a scheduled_id. Nothing is checked at booking time: quotas, suppression, outbound policy and approval all apply at the moment it sends, which is why the outcome lives at /v1/messages/scheduled/{id} rather than in this reply."}}}}}},"responses":{"200":{"description":"Sent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"202":{"description":"Held for a person to review, or booked for later. Accepted, not refused: do not retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Rejected with a reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}}}}},"/v1/messages/scheduled":{"get":{"tags":["Messages"],"summary":"List sends booked for later","description":"Only what has not gone yet. A scheduled message that sent is an ordinary sent email and lives in /v1/messages, where every other one does.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","maximum":100,"default":20}}],"responses":{"200":{"description":"Bookings, soonest first","content":{"application/json":{"schema":{"type":"object","properties":{"scheduled":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledSend"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/messages/scheduled/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Messages"],"summary":"What became of a booking","description":"Whether or not its time has come.","responses":{"200":{"description":"The booking","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduledOutcome"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Messages"],"summary":"Call a booking off","description":"409 rather than 404 when it is too late: the row exists and the request was reasonable, the message is simply already on its way. The status comes back with it, so the caller knows whether to look for a sent message or a refusal.","responses":{"200":{"description":"Cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"scheduled_id":{"type":"string","format":"uuid"},"cancelled":{"type":"boolean"}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Already on its way","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduledOutcome"}}}}}}},"/v1/messages/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Messages"],"summary":"Read a message and mark it read","responses":{"200":{"description":"The full message","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/messages/{id}/archive":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Messages"],"summary":"Archive, restore, or mark unread","description":"An empty body archives the message. Send {\"archived\": false} to restore it, or {\"unread\": true} to put it back in the unread pile for a human.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"archived":{"type":"boolean"},"unread":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated flags","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/messages/{id}/reply-all":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Messages"],"summary":"Reply keeping every recipient","description":"Original sender goes in To, remaining recipients in Cc, your own address is dropped.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["body"],"properties":{"body":{"type":"string"},"attachments":{"type":"array","items":{"type":"object","required":["filename","content_base64"],"properties":{"filename":{"type":"string"},"content_type":{"type":"string","default":"application/octet-stream"},"content_base64":{"type":"string","description":"base64 bytes, no data: prefix"}}}},"track_opens":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Sent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"202":{"description":"Held for a person to review. Queued, not refused: do not retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/messages/{id}/forward":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Messages"],"summary":"Forward a message","description":"inline quotes the original under your note. wrapped attaches the untouched original as message/rfc822, which requires raw MIME and so only works for received mail.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["to"],"properties":{"to":{"type":"array","items":{"type":"string","format":"email"},"minItems":1,"maxItems":50},"body":{"type":"string"},"mode":{"type":"string","enum":["inline","wrapped"],"default":"inline"}}}}}},"responses":{"200":{"description":"Sent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"202":{"description":"Held for a person to review. Queued, not refused: do not retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/messages/{id}/attachments/{filename}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"filename","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Messages"],"summary":"Download an attachment","responses":{"200":{"description":"Raw bytes with the original content type","content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/threads":{"get":{"tags":["Threads"],"summary":"List conversations","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","maximum":100,"default":20}}],"responses":{"200":{"description":"Threads, most recent first","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/threads/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Threads"],"summary":"Load a whole conversation","description":"Every message in the thread, oldest first, so an agent has full context before replying.","responses":{"200":{"description":"The conversation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/search":{"get":{"tags":["Messages"],"summary":"Full-text search the mailbox","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":50,"default":20}}],"responses":{"200":{"description":"Ranked hits with excerpts","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/domains":{"get":{"tags":["Domains"],"summary":"List sending domains","description":"Includes the exact DNS records a pending domain still needs, so an agent can finish setup without opening the dashboard.","responses":{"200":{"description":"Domains","content":{"application/json":{"schema":{"type":"object"}}}}}},"post":{"tags":["Domains"],"summary":"Add a custom domain","description":"Returns the MX, DKIM, SPF, DMARC, and ownership records to publish. The three DKIM records are issued for the domain when it is added, so the response is the complete set; dkim_records_pending is true in the rare case it could not be registered yet, and a later read or verify call carries them.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["domain"],"properties":{"domain":{"type":"string","example":"agents.acme.com"}}}}}},"responses":{"201":{"description":"Created, with records to publish","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Domains"],"summary":"Remove a custom domain by query id","description":"The older spelling of DELETE /v1/domains/{id}, kept working and running the same code. Reach for the path form. The workspace subdomain cannot be removed: every agent lives on it.","deprecated":true,"parameters":[{"name":"id","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/domains/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Domains"],"summary":"One sending domain","description":"The same fields the list returns for this row, including the DNS records to publish while it is still pending. Another workspace's id answers 404 rather than 403, so an id cannot be tested for existence.","responses":{"200":{"description":"The domain","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Domains"],"summary":"Set or clear the tracking host","description":"There is exactly one setting on a domain: tracking_host, the hostname in front of tracked links. Send {\"tracking_host\": \"links.acme.com\"}, a bare label such as \"links\" which is expanded under this domain, or null to go back to the shared host. The work is done by /v1/domains/{id}/tracking, so the CNAME is checked live before it is turned on. There is no open_tracking or click_tracking switch: opens are reported per send and links are never rewritten, so a body carrying either field is refused by name with a 422 rather than accepted and quietly dropped.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tracking_host":{"type":["string","null"],"example":"links.acme.com","description":"Must sit under the sending domain. Null restores the shared host."}}}}}},"responses":{"200":{"description":"The domain, with tracking_host as it now stands","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"The body changed nothing that exists on a domain, or tracking_host was neither a hostname nor null.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"tracking_switch_not_supported when the body carries open_tracking or click_tracking. Those settings do not exist here and the request is refused rather than half-applied. Also tracking_cname_not_found until the CNAME resolves, and tracking_needs_custom_domain on a shared sending subdomain.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Domains"],"summary":"Remove a custom domain","description":"The same operation as DELETE /v1/domains?id=, with the same guards: the workspace subdomain cannot be removed because every agent lives on it, a domain with mailboxes still on it is refused with the count, and the SES identity is deregistered alongside the row.","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"domain_in_use. Deleting the domain would delete those mailboxes and all of their mail, so they have to be removed deliberately first. The count is `error.mailboxes`, inside the error object rather than beside it, which is the same nested shape every other refusal on this route uses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/domains/{id}/verify":{"post":{"tags":["Domains"],"summary":"Check a domain now","description":"Reports which records resolve and whether the domain can send. Three things have to agree: the published records, the domain being accepted for sending, and DKIM signing. dkim_records_pending means the DKIM records themselves are not issued yet; dkim_status pending means they are published and confirmation is in progress, which needs nothing further from the caller. Pending domains are also checked nightly, so this is the fast path rather than the only one.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Current state","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/domains/{id}/tracking":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Domains"],"summary":"The tracking CNAME for this domain","description":"The record that puts your own hostname in front of tracked links and open pixels, instead of emails.sh. Hands back what to publish and whether it resolves yet. The host defaults to click.<domain> and must sit under the sending domain, which must be your own verified domain: a shared sending subdomain is not yours to brand, and a CNAME under a domain we have not proved you own would let anybody put anybody hostname in front of our redirect.","responses":{"200":{"description":"The record, and whether it is live","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackingDomain"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"tracking_needs_custom_domain on a shared sending subdomain, or domain_not_verified before the sending domain itself is verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Domains"],"summary":"Turn tracking on for a hostname","description":"Checks the CNAME live and turns it on. The body is optional: with no host named, the default under this domain is used, which is what the GET told you to publish. Checked rather than taken on trust, because a tracking host that does not resolve would rewrite every link in your next send to a hostname that answers nothing, which is strictly worse than the shared host it replaced.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"host":{"type":"string","example":"click.acme.com","description":"Must be under the sending domain. Defaults to click.<domain>."}}}}}},"responses":{"200":{"description":"Tracking is on","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackingDomain"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"tracking_cname_not_found until the CNAME resolves to the tracking target. DNS takes a few minutes to become visible, and until then your links keep pointing at emails.sh, which is not a failure.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Domains"],"summary":"Go back to the shared tracking host","description":"Future sends use emails.sh again. Nothing already sent breaks: a redirect resolves by link id and does not care which hostname it arrived on. It does mean links in mail already delivered still point at your old hostname, so leave the CNAME published or those links stop resolving. The note field in the response says exactly that, because it is the one thing a caller has to know before tidying up their DNS.","responses":{"200":{"description":"Removed","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/TrackingDomain"},{"type":"object","properties":{"removed":{"type":["string","null"],"description":"The host that was in use, or null."},"note":{"type":"string"}}}]}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/suppressions":{"get":{"tags":["Suppressions"],"summary":"List suppressed addresses","description":"Every address this workspace is refused for: its own bounces, complaints and unsubscribes, plus the addresses suppressed platform-wide. is_global marks the second kind, which cannot be lifted here. Newest first; pass next_cursor back as before for the next page.","parameters":[{"name":"email","in":"query","schema":{"type":"string"},"description":"One exact address."},{"name":"reason","in":"query","schema":{"type":"string","enum":["bounce","complaint","unsub","manual"]}},{"name":"before","in":"query","schema":{"type":"string","format":"date-time"},"description":"The next_cursor from the previous page."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"Suppressions","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Suppressions"],"summary":"Suppress an address","description":"Stops every future send to this address before it leaves. Bounces, complaints and unsubscribes add themselves; this is for the ones you know about first.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","example":"person@example.com"},"reason":{"type":"string","enum":["bounce","complaint","unsub","manual"],"default":"manual"}}}}}},"responses":{"201":{"description":"Suppressed","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Suppressions"],"summary":"Lift a suppression by query id or address","description":"The older spelling of DELETE /v1/suppressions/{id}, kept working and running the same code. Reach for the path form when you have an id; this is the one that also takes an address. Sends to the address are attempted again. Do this only when the reason is understood and gone. Lifting a complaint means mailing somebody who reported you as spam, which damages the sending reputation of every domain on the account. Addresses suppressed platform-wide answer 404.","parameters":[{"name":"email","in":"query","schema":{"type":"string"}},{"name":"id","in":"query","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Lifted","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/suppressions/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"delete":{"tags":["Suppressions"],"summary":"Lift one suppression","description":"Sends to that address are attempted again. The same operation as DELETE /v1/suppressions?id=, running the same code. A row suppressed platform-wide and a row belonging to another workspace both answer 404, in the same words an id that never existed gets: an address suppressed platform-wide is protecting every sender on the service and nothing this workspace does can lift it, so there is no state here to discover. Lift a complaint only when you know why it happened.","responses":{"200":{"description":"Lifted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"string"}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhooks":{"get":{"tags":["Webhooks"],"summary":"List webhook endpoints","description":"Secrets are not returned here; they are shown once, at creation. The response also carries a `delivery` object describing the policy every endpoint is subject to, so a caller reads the numbers rather than hardcoding them: `max_attempts` (7), `retry_delays_sec` (5, 300, 1800, 7200, 18000, 36000: the waits before attempts 2 to 7), `retry_window_sec` (63305, so 17h 35m from the first attempt to the last), `timeout_sec`, `signature_tolerance_sec`, and the two conditions that switch an endpoint off, `auto_disable_after_failures` (20 consecutive failed attempts) and `auto_disable_after_silence_sec` (64800, so 18 hours with nothing accepted). Both have to hold, so a busy workspace burning twenty attempts during a thirty second deploy keeps its endpoint. A 410 Gone bypasses both.","responses":{"200":{"description":"Endpoints","content":{"application/json":{"schema":{"type":"object"}}}}}},"post":{"tags":["Webhooks"],"summary":"Add a webhook endpoint","description":"The response carries the signing secret once and never again. Deliveries are signed x-emailssh-signature: t=<unix>,v1=<hmac-sha256 of \"<t>.<body>\">, and carry x-emailssh-delivery-id, which is stable across retries so a receiver can ignore a repeat. The same id is sent as x-request-id, the name generic receivers already dedup on.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","events"],"properties":{"url":{"type":"string","format":"uri","description":"https only, and reachable from the public internet"},"events":{"type":"array","items":{"type":"string","enum":["email.received","email.sent","email.delivered","email.bounced","email.complained","email.filtered","domain.verified","workspace.throttled","workspace.paused","workspace.resumed","automation.run.started","automation.run.failed"]}},"mailbox_id":{"type":"string","format":"uuid","description":"Deliver only events for this agent. Omit for the whole workspace."},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"Extra request headers, for a receiver that requires its own auth. Values are write-only."},"template":{"type":"object","description":"Body shape override for a receiver that expects its own JSON, with {{event}} and {{data.subject}} placeholders. Omit for the standard envelope."}}}}}},"responses":{"201":{"description":"Created, with the signing secret","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Webhooks"],"summary":"Update a webhook endpoint by query id","description":"The older spelling of PATCH /v1/webhooks/{id}, kept working and running the same code. Reach for the path form. Changes an endpoint in place, keeping its id and its secret. Fields left out keep their current value. Setting active back to true also clears the failure count that turned it off.","deprecated":true,"parameters":[{"name":"id","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"active":{"type":"boolean"},"mailbox_id":{"type":"string","format":"uuid","nullable":true,"description":"Null widens the endpoint back to the whole workspace."},"headers":{"type":"object","additionalProperties":{"type":"string"}},"template":{"type":"object"},"rotate_secret":{"type":"boolean","description":"Replace the signing secret. The new one is returned once, alongside previous_secret_valid_until. The old secret keeps verifying until then and deliveries are signed with both, so the receiver can be updated without a gap. A verifier must accept any v1 in the signature header for this to work."}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Webhooks"],"summary":"Remove a webhook endpoint by query id","description":"The older spelling of DELETE /v1/webhooks/{id}, kept working and running the same code. Reach for the path form.","deprecated":true,"parameters":[{"name":"id","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhooks/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Webhooks"],"summary":"Read one webhook endpoint","description":"The same fields the list returns for this row, including the health ones: fail_count, last_success_at, last_failure_at and auto_disabled_at, which is how you tell \"nothing has happened\" from \"everything I sent you failed\". The signing secret is never returned by a read, at any scope. Configured header values are not returned either, only header_names, because one of them is usually the receiver's own bearer token. An id belonging to another workspace answers 404, not 403.","responses":{"200":{"description":"The endpoint","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Webhooks"],"summary":"Update a webhook endpoint","description":"Changes an endpoint in place, keeping its id and its secret, so a typo in a URL no longer means deleting and recreating and editing the receiver too. Fields left out keep their current value. Setting active back to true also clears the failure count that turned it off.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["email.received","email.sent","email.delivered","email.bounced","email.complained","email.filtered","domain.verified","workspace.throttled","workspace.paused","workspace.resumed","automation.run.started","automation.run.failed"]}},"active":{"type":"boolean"},"mailbox_id":{"type":"string","format":"uuid","nullable":true,"description":"Null widens the endpoint back to the whole workspace."},"headers":{"type":"object","additionalProperties":{"type":"string"}},"template":{"type":"object"},"rotate_secret":{"type":"boolean","description":"Replace the signing secret. The new one is returned once, alongside previous_secret_valid_until. The old secret keeps verifying until then and deliveries are signed with both, so the receiver can be updated without a gap. A verifier must accept any v1 in the signature header for this to work."}}}}}},"responses":{"200":{"description":"Updated, plus the new secret if one was rotated","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"nothing_to_update when no known field was sent, events_required for an empty events array, and the url, header and template refusals. These answer the flat {\"error\": code} shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Webhooks"],"summary":"Remove a webhook endpoint","description":"Events still held for this endpoint go with it, by foreign key.","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"string","format":"uuid"}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhooks/deliveries":{"get":{"tags":["Webhooks"],"summary":"List delivery attempts","description":"What each attempt got back: the status code, the response body, and how long it took. Newest first, kept for thirty days. A status code means the receiver answered and rejected the request; an error with no status means it never arrived, which is DNS, TLS, or a timeout. delivery_id is shared by every attempt at the same event, so a run of rows with one id is one event being retried. A failed attempt that still has attempts owed to it carries a `retry` object of next_attempt, next_attempt_at, attempts_made and attempts_remaining, attached to the newest attempt at that event only and null everywhere else. next_attempt_at is derived from the retry curve rather than stored, so an endpoint that answered 429 with a Retry-After was given that instead and the time shown for that event is wrong: read it as the schedule, not as a promise.","parameters":[{"name":"webhook_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"Omit for every endpoint in the workspace."},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"before","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only attempts older than this. Page with the last row's `at`."}],"responses":{"200":{"description":"Attempts","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Webhooks"],"summary":"Test or replay a delivery","description":"Sends something now and answers with exactly what came back. Nothing is queued and nothing is retried, so the response is the whole result. webhook_id sends a sample email.received event, and one that lands also clears the failure count that would disable the endpoint. delivery_id sends a stored payload again, byte for byte and under its original id, so a receiver that deduplicates recognises the repeat.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"webhook_id":{"type":"string","format":"uuid","description":"Send a test event."},"delivery_id":{"type":"string","format":"uuid","description":"Replay this stored attempt. Takes precedence over webhook_id."}}}}}},"responses":{"200":{"description":"What the endpoint answered","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/analytics":{"get":{"tags":["Analytics"],"summary":"Sending totals and a time series","description":"Counts of what this workspace sent and what happened to it. The default window is the last 30 days and the widest is 400. Rates come back as null, not 0, when the denominator is 0. There is deliberately no open rate: open tracking undercounts by an unknown amount, so the raw opened and opens_tracked counts are given and the division is left to you.","parameters":[{"name":"from","in":"query","schema":{"type":"string","format":"date"},"description":"YYYY-MM-DD, UTC."},{"name":"to","in":"query","schema":{"type":"string","format":"date"},"description":"YYYY-MM-DD, UTC, inclusive."},{"name":"group_by","in":"query","schema":{"type":"string","enum":["day","week","month"],"default":"day"}},{"name":"breakdown","in":"query","schema":{"type":"string","enum":["domain","tag","mail_class","template"]},"description":"Top 20 rows by sent, with the tail folded into one row whose key is null."},{"name":"tag_key","in":"query","schema":{"type":"string"},"description":"Required when breakdown is tag."},{"name":"mail_class","in":"query","schema":{"type":"string","enum":["transactional","marketing"]}},{"name":"domain","in":"query","schema":{"type":"string"}},{"name":"template_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"tag","in":"query","schema":{"type":"string","examples":["campaign:launch"]},"description":"One key:value pair."}],"responses":{"200":{"description":"Totals, series, and an optional breakdown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyticsReport"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/analytics/tags":{"get":{"tags":["Analytics"],"summary":"Which tag keys are worth breaking down by","description":"The 25 most used tag keys in the window, with how much each was sent on. Feed one of them back as tag_key with breakdown=tag.","parameters":[{"name":"days","in":"query","schema":{"type":"integer","minimum":1,"maximum":400,"default":30},"description":"Clamped to 1..400."}],"responses":{"200":{"description":"Tag keys, most used first","content":{"application/json":{"schema":{"type":"object","properties":{"tag_keys":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"sent":{"type":"integer"}}}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments":{"get":{"tags":["Segments"],"summary":"List segments","parameters":[{"name":"audience_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"Only segments scoped to this audience."}],"responses":{"200":{"description":"Segments","content":{"application/json":{"schema":{"type":"object","properties":{"segments":{"type":"array","items":{"$ref":"#/components/schemas/Segment"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Segments"],"summary":"Create a segment","description":"A saved filter over contacts. Give an audience_id to scope it to one list, which is also what status and joined rules need. A name already in use answers 409 segment_exists.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"description":{"type":"string"},"audience_id":{"type":"string","format":"uuid"},"match":{"type":"string","enum":["all","any"],"default":"all"},"rules":{"type":"array","maxItems":20,"items":{"$ref":"#/components/schemas/SegmentRule"}}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Segment"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Segments"],"summary":"Read a segment","description":"member_count is the last stored count, from counted_at. Pass count=live to recompute it now.","parameters":[{"name":"count","in":"query","schema":{"type":"string","enum":["live"]}}],"responses":{"200":{"description":"The segment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Segment"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Segments"],"summary":"Update a segment","description":"Sending rules replaces the whole list. They are never merged, so read the segment first if you are adding one rule.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"audience_id":{"type":["string","null"],"format":"uuid"},"match":{"type":"string","enum":["all","any"]},"rules":{"type":"array","maxItems":20,"items":{"$ref":"#/components/schemas/SegmentRule"}}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Segment"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Segments"],"summary":"Delete a segment","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"deleted":{"type":"boolean"},"scope":{"type":"string","enum":["audience","workspace"]}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments/{id}/members":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Segments"],"summary":"Who is in a segment right now","description":"Evaluated at request time. Page with next_after, which is null on the last page.","parameters":[{"name":"mailable","in":"query","schema":{"type":"boolean"},"description":"true drops anyone this workspace cannot send to."},{"name":"limit","in":"query","schema":{"type":"integer","maximum":1000,"default":100}},{"name":"after","in":"query","schema":{"type":"string"},"description":"The next_after from the previous page."}],"responses":{"200":{"description":"Members","content":{"application/json":{"schema":{"type":"object","properties":{"segment_id":{"type":"string","format":"uuid"},"total":{"type":"integer"},"next_after":{"type":["string","null"]},"members":{"type":"array","items":{"type":"object","properties":{"membership_id":{"type":["string","null"],"format":"uuid"},"contact_id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"status":{"type":"string"},"name":{"type":["string","null"]}}}}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/broadcasts":{"get":{"tags":["Broadcasts"],"summary":"List broadcasts","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","maximum":100,"default":50}}],"responses":{"200":{"description":"Broadcasts, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"broadcasts":{"type":"array","items":{"$ref":"#/components/schemas/Broadcast"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Broadcasts"],"summary":"Create a draft broadcast","description":"One send to a whole audience. Only from is required here, so you can create the draft first and fill in the rest with PATCH. The response carries ready and problems[], which list in plain sentences what is still missing before it can send. The sandbox address onboarding@emails.sh cannot send a broadcast: it answers 422 sandbox_not_allowed_for_broadcasts.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["from"],"properties":{"from":{"type":"string","examples":["Acme <news@acme.com>"]},"name":{"type":"string","description":"Internal label. Recipients never see it."},"audience_id":{"type":"string","format":"uuid"},"segment_id":{"type":"string","format":"uuid","description":"Narrow the audience to the members of this segment."},"topic_id":{"type":"string","format":"uuid","description":"The subscription topic this counts as, for the preference centre."},"reply_to":{"type":"string"},"subject":{"type":"string"},"html":{"type":"string"},"text":{"type":"string"},"template_id":{"type":"string","format":"uuid"},"template_version_id":{"type":"string","format":"uuid"},"track_opens":{"type":"boolean"},"track_clicks":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Created as a draft","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Broadcast"},{"type":"object","properties":{"segment_id":{"type":["string","null"],"format":"uuid"},"ready":{"type":"boolean"},"problems":{"type":"array","items":{"type":"string"}}}}]}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/broadcasts/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Broadcasts"],"summary":"Read a broadcast","responses":{"200":{"description":"The broadcast","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Broadcast"},{"type":"object","properties":{"html":{"type":["string","null"]},"text":{"type":["string","null"]},"problems":{"type":"array","items":{"type":"string"}}}}]}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Broadcasts"],"summary":"Edit a draft","description":"Drafts only. Anything sending, sent, scheduled or cancelled answers 409 broadcast_not_editable. from is fixed at creation and cannot be changed here.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"audience_id":{"type":"string","format":"uuid"},"segment_id":{"type":["string","null"],"format":"uuid"},"topic_id":{"type":["string","null"],"format":"uuid"},"reply_to":{"type":"string"},"subject":{"type":"string"},"html":{"type":"string"},"text":{"type":"string"},"track_opens":{"type":"boolean"},"track_clicks":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Broadcast"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Broadcasts"],"summary":"Cancel a broadcast","description":"Cancels rather than erases, so the record of what went out survives. One that is already finished answers 409 broadcast_not_cancellable.","responses":{"200":{"description":"Cancelled","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/broadcasts/{id}/send":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Broadcasts"],"summary":"Send or schedule a broadcast","description":"With no body it goes now and the response says how many recipients were queued, in how many batches, and how many were skipped as suppressed or unsubscribed. With scheduled_at it is booked instead: the time must be in the future and no more than 30 days out. A draft that is not ready answers 422 broadcast_incomplete with the same sentences GET returns in problems[].","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"scheduled_at":{"type":"string","format":"date-time","description":"RFC 3339, future, up to 30 days out."}}}}}},"responses":{"200":{"description":"Sending now, or booked for later","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string"},"queued":{"type":"integer"},"batches":{"type":"integer"},"skipped":{"type":"integer"},"recipients":{"type":"integer"},"scheduled_at":{"type":"string","format":"date-time"}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/broadcasts/{id}/cancel":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Broadcasts"],"summary":"Cancel a broadcast","description":"The same effect as DELETE /v1/broadcasts/{id}, for callers that would rather not send a DELETE.","responses":{"200":{"description":"Cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string"}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/broadcasts/{id}/test":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Broadcasts"],"summary":"Send a test copy","description":"Up to 5 addresses, rendered the same way the real send will render. Suppressed addresses come back in skipped rather than failing the call.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["to"],"properties":{"to":{"oneOf":[{"type":"string","format":"email"},{"type":"array","maxItems":5,"items":{"type":"string","format":"email"}}]}}}}}},"responses":{"200":{"description":"Test sent","content":{"application/json":{"schema":{"type":"object","properties":{"sent":{"type":"integer"},"skipped":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/broadcasts/preview":{"post":{"tags":["Broadcasts"],"summary":"Render a body that has not been saved","description":"The same render as GET /v1/broadcasts/{id}/preview, with the content in the request instead of in a draft. Nothing is written and nothing is sent. Pass audience_id to render against a real member of that audience; leave it out and the recipient is the placeholder someone@example.com with no attributes, so every merge field comes back supplied: false. id is always null in the response, because nothing was saved.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["subject"],"properties":{"subject":{"type":"string"},"html":{"type":"string"},"text":{"type":"string"},"from":{"type":"string"},"reply_to":{"type":"string"},"audience_id":{"type":"string","format":"uuid","description":"Render for a real member of this audience. Must be on the workspace: another workspace id answers 404."},"email":{"type":"string","format":"email","description":"Render for this member of audience_id. Ignored when audience_id is absent."}}}}}},"responses":{"200":{"description":"The rendered body","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":["string","null"],"format":"uuid"},"rendered_for":{"type":["string","null"],"format":"email"},"from":{"type":["string","null"]},"reply_to":{"type":["string","null"]},"subject":{"type":"string"},"html":{"type":["string","null"]},"text":{"type":["string","null"]},"headers":{"type":"object","additionalProperties":{"type":"string"}},"merge_fields":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"supplied":{"type":"boolean"}}}},"audience_sample_size":{"type":"integer"}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/broadcasts/{id}/preview":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Broadcasts"],"summary":"Render the broadcast without sending it","description":"merge_fields is the useful part: every {{ field }} in the body with whether the audience actually supplies it, checked against a sample of members. A field that is not supplied will fail the send.","parameters":[{"name":"email","in":"query","schema":{"type":"string","format":"email"},"description":"Render for this member. Omit to render with sample values."}],"responses":{"200":{"description":"The rendered broadcast","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":["string","null"],"format":"uuid"},"rendered_for":{"type":["string","null"],"format":"email"},"from":{"type":["string","null"]},"reply_to":{"type":["string","null"]},"subject":{"type":"string"},"html":{"type":["string","null"]},"text":{"type":["string","null"]},"headers":{"type":"object","additionalProperties":{"type":"string"}},"merge_fields":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"supplied":{"type":"boolean"}}}},"audience_sample_size":{"type":"integer"}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/broadcasts/{id}/recipients":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Broadcasts"],"summary":"Per-recipient outcome","description":"One row per address, with why it was skipped or how it bounced. stats carries the same counters as the broadcast plus computed rates.","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["pending","sent","delivered","bounced","complained","failed","skipped"]}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":500,"default":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Recipients","content":{"application/json":{"schema":{"type":"object","properties":{"stats":{"type":"object"},"recipients":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"status":{"type":"string"},"reason":{"type":["string","null"]},"message_id":{"type":["string","null"],"format":"uuid"},"sent_at":{"type":["string","null"],"format":"date-time"},"opened_at":{"type":["string","null"],"format":"date-time"},"clicked_at":{"type":["string","null"],"format":"date-time"}}}}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts":{"get":{"tags":["Contacts"],"summary":"List contacts","description":"Contacts are workspace-level people, separate from audience memberships. Ask for Accept: text/vcard to get the same list as a .vcf file.","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Free-text search over names, companies and addresses."},{"name":"lookup","in":"query","schema":{"type":"string"},"description":"One exact address or handle."},{"name":"limit","in":"query","schema":{"type":"integer","maximum":100,"default":25}}],"responses":{"200":{"description":"Contacts","content":{"application/json":{"schema":{"type":"object","properties":{"contacts":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}}}}},"text/vcard":{"schema":{"type":"string"}}}},"401":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"post":{"tags":["Contacts"],"summary":"Create contacts","description":"Two modes. Post vcard with the text of one or more cards, up to 1000, and each is parsed into a contact. Or post the fields directly, in which case at least one of given_name, family_name, preferred_name or company_name is required and anything else answers 400 name_required.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vcard":{"type":"string","description":"Raw vCard text. Up to 1000 cards in one request."},"given_name":{"type":"string"},"family_name":{"type":"string"},"preferred_name":{"type":"string"},"company_name":{"type":"string"},"job_title":{"type":"string"},"notes":{"type":"string"},"domains":{"type":"array","items":{"type":"string"}},"channels":{"type":"array","description":"Addresses and handles this person can be reached on.","items":{"type":"object"}}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"401":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/contacts/duplicates":{"get":{"tags":["Contacts"],"summary":"Find likely duplicate contacts","description":"Groups that share an address, a handle, or a name and company.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","maximum":50,"default":25}}],"responses":{"200":{"description":"Candidate groups","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"post":{"tags":["Contacts"],"summary":"Merge two contacts","description":"The loser is folded into the survivor and stops existing. Channels, tags and attributes move across; the survivor keeps its own values where both have one.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["survivor_id","loser_id"],"properties":{"survivor_id":{"type":"string","format":"uuid"},"loser_id":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Merged","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/contacts/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Contacts"],"summary":"Read a contact","responses":{"200":{"description":"The contact","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"patch":{"tags":["Contacts"],"summary":"Update a contact","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"given_name":{"type":"string"},"family_name":{"type":"string"},"preferred_name":{"type":"string"},"company_name":{"type":"string"},"job_title":{"type":"string"},"notes":{"type":"string"},"domains":{"type":"array","items":{"type":"string"}},"channels":{"type":"array","items":{"type":"object"}}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"delete":{"tags":["Contacts"],"summary":"Delete a contact","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/contacts/{id}/tags":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Contacts"],"summary":"List a contact tags","responses":{"200":{"description":"Tags, lowercased","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Contacts"],"summary":"Add tags","description":"Send tags as an array or tag as one string. A tag is 1 to 64 characters after trimming, with no comma, carriage return or line feed. Tags are compared without regard to case and stored lowercased, so VIP and vip are the same tag. Every tag that was not already held queues a tag.added automation event, swept every 5 minutes.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":64}},"tag":{"type":"string","minLength":1,"maxLength":64}}}}}},"responses":{"201":{"description":"Tags after the change, and which were new","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"added":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Contacts"],"summary":"Remove one tag","description":"A tag the contact does not hold answers 404. Removing one queues a tag.removed automation event.","parameters":[{"name":"tag","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Tags after the change","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"removed":{"type":"string"}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/{id}/attributes":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Contacts"],"summary":"Read contact attributes","description":"Workspace-level facts about a person, stored as text and read by segments and automations. These are not the same store as the per-list merge fields on an audience membership.","responses":{"200":{"description":"Attributes","content":{"application/json":{"schema":{"type":"object","properties":{"attributes":{"type":"object","additionalProperties":{"type":"string"}}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Contacts"],"summary":"Merge contact attributes","description":"A merge patch: names you do not send keep their value, and a null clears one. Send {\"attributes\": {...}} or the bare object. Between 1 and 100 names per call.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"attributes":{"type":"object","additionalProperties":{"type":["string","null"]}}}}}}},"responses":{"200":{"description":"Attributes after the merge, and which names moved","content":{"application/json":{"schema":{"type":"object","properties":{"attributes":{"type":"object","additionalProperties":{"type":"string"}},"changed":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/{id}/subscriptions":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Contacts"],"summary":"Everything a contact is subscribed to","description":"One entry per address the contact holds, with the audiences and topics it is on and whether it is suppressed.","responses":{"200":{"description":"Subscriptions","content":{"application/json":{"schema":{"type":"object","properties":{"contact_id":{"type":"string","format":"uuid"},"subscriptions":{"type":"array","items":{"type":"object","properties":{"email":{"type":"string","format":"email"},"suppressed":{"type":"boolean"},"audiences":{"type":"array","items":{"type":"object"}},"topics":{"type":"array","items":{"type":"object"}}}}}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/topics":{"get":{"tags":["Topics"],"summary":"List topics","description":"The subjects a recipient can opt out of one at a time, instead of all mail at once.","parameters":[{"name":"include_archived","in":"query","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Topics","content":{"application/json":{"schema":{"type":"object","properties":{"topics":{"type":"array","items":{"$ref":"#/components/schemas/Topic"}}}}}}},"401":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"post":{"tags":["Topics"],"summary":"Create a topic","description":"key is what you reference from a send, and it is derived from the name when you leave it out. A required topic cannot be opted out of, which is for things like security notices.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","examples":["Product updates"]},"key":{"type":"string","minLength":2,"description":"Lowercase letters, digits and hyphens.","examples":["product-updates"]},"description":{"type":"string"},"default_opt_in":{"type":"boolean","default":true},"required":{"type":"boolean","default":false}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Topic"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"409":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/topics/preferences":{"get":{"tags":["Topics"],"summary":"What one address is opted into","description":"stated is whether the person has said anything; subscribed is what the send would honour, which falls back to the topic default when they have not.","parameters":[{"name":"email","in":"query","required":true,"schema":{"type":"string","format":"email"}}],"responses":{"200":{"description":"Preferences","content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"preference_url":{"type":"string","format":"uri"},"preferences":{"type":"array","items":{"type":"object","properties":{"topic":{"$ref":"#/components/schemas/Topic"},"stated":{"type":"boolean"},"subscribed":{"type":"boolean"},"unsubscribe_url":{"type":"string","format":"uri"}}}}}}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"post":{"tags":["Topics"],"summary":"Set or read one preference","description":"Leave subscribed out to read the current answer without writing one. Opting out of a required topic answers 409 topic_required_no_opt_out.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","topic"],"properties":{"email":{"type":"string","format":"email"},"topic":{"type":"string","description":"The topic key or its id."},"subscribed":{"type":"boolean"},"source":{"type":"string","description":"Where the change came from, kept for the audit trail."}}}}}},"responses":{"200":{"description":"The preference","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"409":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/topics/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Topics"],"summary":"Read a topic","responses":{"200":{"description":"The topic","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Topic"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"patch":{"tags":["Topics"],"summary":"Update a topic","description":"key is fixed once created, because sends and preference links reference it. Sending a different one answers 400 topic_key_immutable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"default_opt_in":{"type":"boolean"},"required":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Topic"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"delete":{"tags":["Topics"],"summary":"Archive a topic","description":"Archived, not erased, so the opt-outs recorded against it stay honoured.","responses":{"200":{"description":"Archived","content":{"application/json":{"schema":{"type":"object","properties":{"archived":{"type":"string","format":"uuid"}}}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/templates":{"get":{"tags":["Templates"],"summary":"List templates","responses":{"200":{"description":"Templates","content":{"application/json":{"schema":{"type":"object","properties":{"templates":{"type":"array","items":{"$ref":"#/components/schemas/Template"}}}}}}},"401":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"post":{"tags":["Templates"],"summary":"Create a template","description":"Creates the template and its first version in one call. Nothing is sendable until a version is published, so pass publish: true if you want to use it right away.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"slug":{"type":"string","description":"A stable handle for referencing it from code. 400 slug_invalid, 409 slug_taken."},"description":{"type":"string"},"subject":{"type":"string"},"html":{"type":"string"},"text":{"type":"string"},"variables":{"$ref":"#/components/schemas/TemplateVariables"},"publish":{"type":"boolean","default":false}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"409":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/templates/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Templates"],"summary":"Read a template with its versions","responses":{"200":{"description":"The template","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Template"},{"type":"object","properties":{"versions":{"type":"array","items":{"$ref":"#/components/schemas/TemplateVersion"}}}}]}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"patch":{"tags":["Templates"],"summary":"Rename a template","description":"Metadata only. Subject, html, text and variables are never edited in place: post a new version instead, so what already went out stays reproducible.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"},"description":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"409":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"delete":{"tags":["Templates"],"summary":"Delete a template","description":"A soft delete, so an email sent from it can still be traced back.","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"string","format":"uuid"}}}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/templates/{id}/versions":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Templates"],"summary":"List versions","responses":{"200":{"description":"Versions, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"versions":{"type":"array","items":{"$ref":"#/components/schemas/TemplateVersion"}},"published_version_id":{"type":["string","null"],"format":"uuid"}}}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"post":{"tags":["Templates"],"summary":"Add a version","description":"One of html or text is required. Creating a version never publishes it: sends keep using the published one until you call publish.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"subject":{"type":"string","default":""},"html":{"type":"string"},"text":{"type":"string"},"variables":{"$ref":"#/components/schemas/TemplateVariables"}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateVersion"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/templates/{id}/publish":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Templates"],"summary":"Publish a version","description":"Name the version by id or by number. With neither, the latest version is published.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"version_id":{"type":"string","format":"uuid"},"version":{"type":"integer"}}}}}},"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"400":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/templates/{id}/render":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Templates"],"summary":"Render the published version","description":"Strict, and the same code path a send uses. A template with no published version answers 422 template_not_published, and a missing variable with no default answers 422 template_variables_missing with the names in missing[].","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"variables":{"type":"object","additionalProperties":true},"preheader":{"type":"string","description":"The line preview panes show after the subject."}}}}}},"responses":{"200":{"description":"Rendered","content":{"application/json":{"schema":{"type":"object","properties":{"template_id":{"type":"string","format":"uuid"},"template_version_id":{"type":"string","format":"uuid"},"subject":{"type":"string"},"html":{"type":["string","null"]},"text":{"type":["string","null"]},"preheader":{"type":["string","null"]}}}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"422":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/templates/{id}/preview":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Templates"],"summary":"Preview any version, including a draft","description":"Lenient where render is strict: a variable with no value is filled with a sample and reported in filled_with_samples rather than refused. warnings flags HTML that email clients handle badly, with codes style_block, external_stylesheet, flex_or_grid, positioning, img_without_alt, unsupported_element and background_image.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"variables":{"type":"object","additionalProperties":true},"version_id":{"type":"string","format":"uuid","description":"Omit to preview the latest version."},"preheader":{"type":"string"}}}}}},"responses":{"200":{"description":"Preview","content":{"application/json":{"schema":{"type":"object","properties":{"template_id":{"type":"string","format":"uuid"},"version_id":{"type":"string","format":"uuid"},"version":{"type":"integer"},"published":{"type":"boolean"},"subject":{"type":"string"},"html":{"type":["string","null"]},"text":{"type":["string","null"]},"preheader":{"type":["string","null"]},"values":{"type":"object","additionalProperties":true},"filled_with_samples":{"type":"array","items":{"type":"string"}},"warnings":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"422":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/automations":{"get":{"tags":["Automations"],"summary":"List automations","responses":{"200":{"description":"Automations","content":{"application/json":{"schema":{"type":"object","properties":{"automations":{"type":"array","items":{"$ref":"#/components/schemas/Automation"}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Automations"],"summary":"Create an automation","description":"An automation is a YAML document: a trigger, optional when filters, and a list of steps. Post the YAML directly with a yaml or text/plain content type, or wrap it as {\"yaml\": \"...\"} in JSON. The response carries the stored YAML back.","requestBody":{"required":true,"content":{"application/yaml":{"schema":{"type":"string"}},"text/plain":{"schema":{"type":"string"}},"application/json":{"schema":{"type":"object","required":["yaml"],"properties":{"yaml":{"type":"string"}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Automation"},{"type":"object","properties":{"yaml":{"type":"string"}}}]}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/automations/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Automations"],"summary":"Read an automation","description":"graph is the parsed shape, for drawing it. trigger_url is set only when the trigger is api.call, and is null otherwise.","responses":{"200":{"description":"The automation","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Automation"},{"type":"object","properties":{"trigger_config":{"type":"object"},"yaml":{"type":"string"},"graph":{"type":"object"},"trigger_url":{"type":["string","null"],"format":"uri"}}}]}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Automations"],"summary":"Turn one on or off, or replace its YAML","description":"Sending yaml saves a new version. Turning one off leaves runs already waiting where they are.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"},"yaml":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"enabled":{"type":"boolean"},"version":{"type":"integer"}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Automations"],"summary":"Delete an automation","description":"Cancels every run still waiting inside it.","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/automations/{id}.yaml":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Automations"],"summary":"Read the raw YAML","description":"The document byte for byte, with comments and key order intact, so a round trip through GET and PUT changes nothing you did not change. The current version number comes back in x-emailssh-automation-version.","responses":{"200":{"description":"The YAML source","headers":{"x-emailssh-automation-version":{"schema":{"type":"integer"},"description":"The version this body is."}},"content":{"application/yaml":{"schema":{"type":"string"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"tags":["Automations"],"summary":"Replace the raw YAML","description":"The body is the YAML document itself, not JSON around it. Saving creates a new version and answers with the stored source.","requestBody":{"required":true,"content":{"application/yaml":{"schema":{"type":"string"}}}},"responses":{"200":{"description":"Saved","headers":{"x-emailssh-automation-version":{"schema":{"type":"integer"}}},"content":{"application/yaml":{"schema":{"type":"string"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/automations/{id}/versions":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Automations"],"summary":"List saved versions","description":"The last 50, newest first, each with the YAML as it was.","responses":{"200":{"description":"Versions","content":{"application/json":{"schema":{"type":"object","properties":{"versions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"version":{"type":"integer"},"source":{"type":"string"},"note":{"type":["string","null"]},"yaml":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}}}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Automations"],"summary":"Restore an old version","description":"The old YAML is saved again as a new version on top, so the history stays a straight line.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["version_id"],"properties":{"version_id":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Restored as a new version","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"version":{"type":"integer"},"yaml":{"type":"string"}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/automations/{id}/trigger":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Automations"],"summary":"Start a run by hand","description":"For an automation whose trigger is api.call. idempotency_key is required, and a repeat answers 409 duplicate rather than starting a second run. 422 covers the reasons a run will not start: automation_disabled, already_enrolled, run_in_flight, concurrency_cap and hourly_cap.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["idempotency_key"],"properties":{"email":{"type":"string","format":"email","description":"One of email or contact_id is required."},"contact_id":{"type":"string","format":"uuid"},"idempotency_key":{"type":"string"},"data":{"type":"object","description":"Read inside the automation as {{ trigger.<name> }}.","additionalProperties":true}}}}}},"responses":{"202":{"description":"Run started","content":{"application/json":{"schema":{"type":"object","properties":{"run_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/automations/{id}/runs":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Automations"],"summary":"List runs","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["running","waiting","completed","stopped","failed","cancelled"]}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":200,"default":50}}],"responses":{"200":{"description":"Runs, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"runs":{"type":"array","items":{"$ref":"#/components/schemas/AutomationRun"}}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/automations/{id}/runs/{runId}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"runId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Automations"],"summary":"Read one run and every step it took","description":"The step objects are camelCase, which is the wire as the runner has always written it. Renaming them here would describe an API nobody receives.","responses":{"200":{"description":"The run","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AutomationRun"},{"type":"object","properties":{"steps":{"type":"array","items":{"type":"object","properties":{"nodeId":{"type":"string"},"kind":{"type":"string","enum":["trigger","condition","action","wait"]},"tool":{"type":["string","null"]},"status":{"type":"string","enum":["ok","skipped","error","branch_true","branch_false","waiting","timed_out"]},"result":{"type":["object","null"]},"error":{"type":["string","null"]}}}}}}]}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Automations"],"summary":"Cancel a waiting run","description":"Only a run in waiting can be cancelled. One that is mid-step answers 409.","responses":{"200":{"description":"Cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"cancelled":{"type":"boolean"}}}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/ips":{"get":{"tags":["Dedicated IPs"],"summary":"List dedicated addresses and the shared pools","description":"Every address this workspace sends from, and the shared pools its mail falls back to when it has none of its own. The pools are in the response on purpose: a workspace with one transactional address still sends its broadcasts down the shared marketing pool. residency and default_region are read-only: region is resolved from the sending domain, then the workspace, then eu.","responses":{"200":{"description":"Addresses and pools","content":{"application/json":{"schema":{"type":"object","properties":{"default_region":{"type":"string","enum":["us","eu"]},"residency":{"type":"object","properties":{"sending":{"type":"string"},"storage":{"type":"string"},"compute":{"type":"string"}}},"ips":{"type":"array","items":{"$ref":"#/components/schemas/DedicatedIp"}},"shared_pools":{"type":"array","items":{"type":"object","properties":{"mail_class":{"type":["string","null"]},"label":{"type":"string"},"pool_name":{"type":"string"}}}}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Dedicated IPs"],"summary":"Take a dedicated address out of inventory","description":"The class travels with the address rather than being chosen at send time, which is the whole reason this takes mail_class. An address that spent three weeks warming on newsletters carries a newsletter reputation, and handing it to receipts would move the receipts onto it: marketing and transactional never share an address, at any point in its life. Warmup starts immediately and cannot be skipped, so daily_cap on the response is a small number on day one and the ramp is on the row.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"mail_class":{"type":"string","enum":["transactional","marketing"],"default":"transactional"},"region":{"type":"string","enum":["us","eu"],"description":"Omit to use this workspace's default."}}}}}},"responses":{"201":{"description":"Assigned, and warming from today","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DedicatedIp"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"plan_required. A dedicated address is billed monthly and is a deliverability decision rather than a free-tier feature. Until you upgrade, your mail goes down the shared pools listed by GET /v1/ips, which is the right answer for most senders below a few hundred thousand messages a month.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"no_address_available. Genuinely out of stock, which is a real state for a finite resource and not a caller error. 503 rather than 409 so that \"retry\" is the obvious reading: addresses return to the pool as workspaces release them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/ips/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Dedicated IPs"],"summary":"Read one address, with its reverse DNS check","description":"reverse_dns compares what the address resolves to against what it should, and forwardConfirmed says whether that name resolves back to this address.","responses":{"200":{"description":"The address","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/DedicatedIp"},{"type":"object","properties":{"reverse_dns":{"type":"object","properties":{"ip":{"type":"string"},"expected":{"type":["string","null"]},"found":{"type":["string","null"]},"forwardConfirmed":{"type":"boolean"},"ok":{"type":"boolean"}}}}}]}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Dedicated IPs"],"summary":"Pause an address or lower its daily cap","description":"These two fields and nothing else. daily_cap is a non-negative integer or null for no cap of your own, and it cannot be raised above what the warmup schedule allows on the current day. A retired address refuses the change.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"paused":{"type":"boolean"},"daily_cap":{"type":["integer","null"],"minimum":0}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DedicatedIp"}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Dedicated IPs"],"summary":"Hand a dedicated address back","description":"Returns the address to the pool. Sends fall back to the shared pool for their class immediately, so this does not stop mail. Whatever warmup the address had accumulated stops being yours, which is why pausing is the thing to do while a reputation problem is being investigated and releasing is the thing to do when you are finished with it.","responses":{"200":{"description":"Released","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ip":{"type":"string"},"released":{"type":"boolean"},"note":{"type":"string"}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/identity":{"get":{"tags":["Identity"],"summary":"The identity this key is bound to","description":"The receiving handle behind the key, with its display name, description, filter mode and its mail rules. A key that is not bound to an identity answers 404 no_identity, which is a fact about the key rather than an error to retry.","responses":{"200":{"description":"The identity and its rules","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Identity"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"patch":{"tags":["Identity"],"summary":"Rename the identity or change how it filters","description":"handle, display_name, description and filter_mode, and nothing else. The handle is 3 to 63 characters, lowercase letters, digits and single hyphens, and it is the local part of the receiving address, so changing it changes where mail arrives. filter_mode blacklist means the rules say who is blocked, whitelist means they say who is allowed and everybody else is not.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"handle":{"type":"string","example":"support"},"display_name":{"type":["string","null"]},"description":{"type":["string","null"]},"filter_mode":{"type":"string","enum":["blacklist","whitelist"]}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Identity"}}}},"400":{"description":"invalid_json, invalid_handle, invalid_filter_mode, or nothing_to_update when the body named no field this endpoint changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"409":{"description":"handle_taken. Somebody already receives on that handle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/identity/rules":{"get":{"tags":["Identity"],"summary":"The mail rules on this identity","description":"Returned whole, so has_more is false and next_cursor is null and a generic pager terminates against it.","responses":{"200":{"description":"The rules","content":{"application/json":{"schema":{"type":"object","properties":{"rules":{"type":"array","items":{"$ref":"#/components/schemas/MailRule"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}},"post":{"tags":["Identity"],"summary":"Add or replace one mail rule","description":"Idempotent on identity, direction, match and value: posting the same rule again changes its action rather than raising. match exact_email needs a full address, domain takes a bare domain and a leading @ is stripped, and recipient matches your own side of the mailbox and is inbound only, because outbound has no \"our side\" to match and there recipient is what exact_email already is. Unrecognised values fall back to match exact_email, action block and direction in.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["value"],"properties":{"match":{"type":"string","enum":["exact_email","domain","recipient"],"default":"exact_email"},"value":{"type":"string","example":"noreply@example.com"},"action":{"type":"string","enum":["allow","block"],"default":"block"},"direction":{"type":"string","enum":["in","out"],"default":"in"}}}}}},"responses":{"201":{"description":"The rule as it now stands","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailRule"}}}},"400":{"description":"invalid_json, invalid_value for a value that does not fit the match, or invalid_match for a recipient rule sent with direction out.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/identity/rules/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"delete":{"tags":["Identity"],"summary":"Remove one mail rule","description":"Scoped to this identity in the query, so another identity's rule id answers rule_not_found rather than being deleted.","responses":{"200":{"description":"Removed","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Flat error. These routes answer {\"error\": \"snake_case_code\"} rather than the nested Error shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlatError"}}}}}}},"/v1/mailboxes":{"get":{"tags":["Mailboxes"],"summary":"List the inboxes in this workspace","description":"Every inbox on the workspace, not only the one this key is scoped to: listing them is how an agent finds the colleague to hand work to. Carries included and used, the inbox count the plan covers and the number in use. Inboxes are counted in tens, so the list comes back whole with has_more false and next_cursor null.","responses":{"200":{"description":"The inboxes","content":{"application/json":{"schema":{"type":"object","properties":{"mailboxes":{"type":"array","items":{"$ref":"#/components/schemas/Mailbox"}},"included":{"type":"integer"},"used":{"type":"integer"},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Mailboxes"],"summary":"Create an inbox","description":"One handle in, a live address out. With no domain_id the workspace subdomain is used, so {\"handle\":\"support\"} is the whole body. Pass domain_id to put the inbox on one of your own verified domains, from GET /v1/domains.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["handle"],"properties":{"handle":{"type":"string","example":"support","description":"Becomes the local part, so support@yourdomain.com."},"domain_id":{"type":"string","format":"uuid"},"display_name":{"type":"string"},"description":{"type":"string","description":"What this inbox is for. Read by other agents deciding where to send work."}}}}}},"responses":{"201":{"description":"Created, and receiving","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"address":{"type":"string"},"handle":{"type":"string"}}}}}},"400":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"inbox_limit_reached. The workspace is at its included inbox count. Paid plans meter past the included number rather than refusing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such domain on this workspace, or no sending subdomain to put an inbox on.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"That handle is already taken on that domain.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/automations/hooks/{token}":{"parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","example":"ahk_0f3c..."},"description":"The token from `trigger_url` on GET /v1/automations/{id}. It changes if the automation is deleted and recreated."}],"post":{"tags":["Automations"],"summary":"Start an automation from anywhere","description":"The URL GET /v1/automations/{id} hands out as trigger_url, for the callers that cannot hold an API key: Stripe, a form host, a Zapier step, somebody's serverless function. Unauthenticated by design, because the token is the credential: it is ahk_ plus 32 hex characters, it names one automation on one workspace, it cannot read, and an unknown token and a deleted automation answer identically so it cannot be used to enumerate. Rate limited per token rather than per IP, since the caller is a stranger's server. idempotency_key is required, not optional, and may be sent as an Idempotency-Key header instead: those callers all retry, and a webhook redelivered twenty minutes later must not send a second welcome email. The key is scoped to the automation, so two automations may both use \"signup-8121\" and mean two different things.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["idempotency_key"],"properties":{"email":{"type":"string","example":"person@example.com","description":"A contact is found or created from the address."},"contact_id":{"type":"string","format":"uuid","description":"Instead of email. Must belong to this automation's workspace."},"data":{"type":"object","description":"Merged into the run context, readable by the automation's steps."},"idempotency_key":{"type":"string","example":"signup-8121","description":"Derive it from the thing that happened. Or send an Idempotency-Key header."}}}}}},"responses":{"202":{"description":"Enrolled, and the run has started","content":{"application/json":{"schema":{"type":"object","properties":{"run_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["started"]}}}}}},"400":{"description":"Invalid JSON, no email and no contact_id, a contact_id from another workspace, or no idempotency_key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"That trigger URL does not belong to an automation. The same answer for a token that never existed, one whose automation was deleted, and one belonging to somebody else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"duplicate. A run with that idempotency key already exists, so nothing was started. That is the endpoint working.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"automation_disabled, already_enrolled, run_in_flight, concurrency_cap, or hourly_cap, which is the loop guard. Each carries a sentence saying which reentry setting changes the answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Automations"],"summary":"What this trigger URL is","description":"A GET is somebody pasting the URL into a browser to see whether it works. Answers 405 with the automation's name and what to POST, rather than an empty method-not-allowed, because the person doing it is mid-integration and is about to guess.","security":[],"responses":{"404":{"description":"Structured error with an actionable reason","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"405":{"description":"method_not_allowed, naming the automation and the body to POST.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","description":"An esh_ API key from https://emails.sh/dashboard/api-keys, scoped to one workspace."}},"schemas":{"Identity":{"type":"object","description":"The receiving handle an API key is bound to.","properties":{"id":{"type":"string","format":"uuid"},"handle":{"type":"string","examples":["support"]},"display_name":{"type":["string","null"]},"description":{"type":["string","null"]},"filter_mode":{"type":"string","enum":["blacklist","whitelist"]},"mailbox_id":{"type":["string","null"],"format":"uuid"},"rules":{"type":"array","items":{"$ref":"#/components/schemas/MailRule"}}}},"MailRule":{"type":"object","description":"One allow or block rule on an identity.","properties":{"id":{"type":"string","format":"uuid"},"match":{"type":"string","enum":["exact_email","domain","recipient"]},"value":{"type":"string","examples":["noreply@example.com"]},"action":{"type":"string","enum":["allow","block"]},"direction":{"type":"string","enum":["in","out"]}}},"Mailbox":{"type":"object","description":"One inbox in the workspace.","properties":{"id":{"type":"string","format":"uuid"},"address":{"type":"string","examples":["support@acme.com"]},"handle":{"type":["string","null"]},"display_name":{"type":["string","null"]},"description":{"type":["string","null"],"description":"What this inbox is for, read by another agent deciding where to send work."},"status":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}},"Error":{"type":"object","description":"Every refusal has this shape. `code` is stable and safe to switch on; `message` says what happened; `next` says what to do about it, in prose, because the thing reading the error is often a coding assistant halfway through wiring up a signup flow. Never match on `message` or `next`: both are improved over time.","properties":{"error":{"type":"object","properties":{"code":{"type":"string","examples":["invalid_from_domain","recipient_suppressed"]},"message":{"type":"string","examples":["the domain acme.com is not verified on this workspace"]},"next":{"type":"string","examples":["Verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while you are testing."]},"retry_after":{"type":"integer","description":"Seconds to wait, on a 429. Present only when waiting is the right answer."}},"required":["code"]}},"required":["error"]},"FlatError":{"type":"object","description":"The second error shape. Contacts, topics, suppressions and templates answer with the code at the top level instead of nested under error, and some carry a message or a hint alongside it. Switch on `error` on those routes and on `error.code` everywhere else. Which shape a route uses is marked on each response below.","required":["error"],"properties":{"error":{"type":"string","examples":["name_required","topic_key_taken","slug_invalid"]},"message":{"type":"string","examples":["a contact needs at least one name field"]},"hint":{"type":"string","examples":["Send given_name, family_name, preferred_name, or company_name."]}}},"SendEmail":{"type":"object","required":["from","to","subject"],"properties":{"from":{"type":"string","description":"An address, or \"Name <address>\". The domain must be verified on this workspace, or be onboarding@emails.sh while testing.","examples":["Acme <hello@acme.com>"]},"to":{"type":"array","items":{"type":"string","format":"email"},"minItems":1,"maxItems":50,"description":"Up to 50 addresses counted across to, cc and bcc together. More than that is what /v1/emails/batch is for."},"cc":{"type":"array","items":{"type":"string","format":"email"},"maxItems":50},"bcc":{"type":"array","items":{"type":"string","format":"email"},"maxItems":50},"subject":{"type":"string"},"html":{"type":"string","description":"HTML body. Give html, text, or both; both is best."},"text":{"type":"string","description":"Plain-text body, sent as the alternative part."},"reply_to":{"oneOf":[{"type":"string","format":"email"},{"type":"array","items":{"type":"string","format":"email"}}]},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"Extra headers, passed through verbatim. `In-Reply-To` and `References` are accepted and thread the message: when they name a message on this workspace the send is filed onto that conversation, and when they name one 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 itself and are refused with 400 reserved_header. Names are printable ASCII with no spaces and no colon."},"attachments":{"type":"array","items":{"type":"object","required":["filename","content_base64"],"properties":{"filename":{"type":"string"},"content_type":{"type":"string","default":"application/octet-stream"},"content_base64":{"type":"string","description":"base64 bytes, no data: prefix"}}},"maxItems":10},"tags":{"type":"object","additionalProperties":{"type":"string"},"description":"Stored with the email and echoed on every webhook for it. Never shown to the recipient."},"send_at":{"type":"string","description":"When to send it, up to 30 days out. An ISO 8601 timestamp such as 2026-08-04T09:00:00Z, a relative offset such as \"in 1 min\", \"in 2 hours\" or \"in 3 days\", or a clock time such as \"tomorrow at 9am\", \"today at 17:30\" or \"friday at 3pm\". A clock time with no offset on it is read as UTC. A named timezone such as \"3pm ET\" is refused rather than guessed at, and so is \"next tuesday\", because it means two different days to two different people: say \"tuesday\" for the next one. The status comes back as scheduled and DELETE /v1/emails/{id} cancels it."},"idempotency_key":{"type":"string","description":"Your own id for this send, usually derived from what caused it. A repeat within 24 hours returns the first result and sends nothing."}}},"Queued":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string","examples":["em_01J9X8Q2K7Y4RN3M"]},"status":{"type":"string","enum":["queued","scheduled"]}}},"Email":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["queued","scheduled","sent","delivered","bounced","complained","canceled"]},"from":{"type":"string"},"to":{"type":"array","items":{"type":"string","format":"email"}},"subject":{"type":"string"},"tags":{"type":"object","additionalProperties":{"type":"string"}},"test_mode":{"type":"boolean","description":"True when the email went to one of the reserved test addresses (delivered@emails.sh and friends) and therefore never left our servers. Present on every message, never absent."},"created_at":{"type":"string","format":"date-time"},"events":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"at":{"type":"string","format":"date-time"},"bounce_type":{"type":"string","enum":["permanent","transient"]},"bounce_subtype":{"type":"string"},"diagnostic":{"type":"string","description":"The remote server's own words."}}}}}},"SendResult":{"type":"object","properties":{"status":{"type":"string","enum":["sent","scheduled","rejected"]},"messageId":{"type":"string","format":"uuid"},"reason":{"type":"string","examples":["daily_send_quota_exhausted","recipient_suppressed"]},"retryAfter":{"type":"string","format":"date-time"},"scheduled_id":{"type":"string","format":"uuid","description":"Set when status is scheduled. The mixed case is the wire's own: the send path has always answered in camelCase and the scheduling path in snake, and renaming either here would describe an API nobody receives."},"send_at":{"type":"string","format":"date-time"}},"required":["status"]},"ScheduledSend":{"type":"object","properties":{"scheduled_id":{"type":"string","format":"uuid"},"subject":{"type":"string"},"to":{"type":"array","items":{"type":"string","format":"email"}},"send_at":{"type":"string","format":"date-time"},"booked_at":{"type":"string","format":"date-time"}}},"ScheduledOutcome":{"type":"object","properties":{"scheduled_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending","sent","failed","cancelled"]},"subject":{"type":"string"},"to":{"type":"array","items":{"type":"string","format":"email"}},"send_at":{"type":"string","format":"date-time"},"message_id":{"type":"string","format":"uuid","nullable":true},"failure_reason":{"type":"string","nullable":true}}},"Attachment":{"type":"object","properties":{"filename":{"type":"string"},"contentType":{"type":"string"},"size":{"type":"integer"}}},"Message":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"thread_id":{"type":["string","null"],"format":"uuid"},"direction":{"type":"string","enum":["in","out"]},"from":{"type":"string"},"to":{"type":"array","items":{"type":"string"}},"subject":{"type":"string"},"snippet":{"type":"string"},"unread":{"type":"boolean"},"status":{"type":"string"},"spam_verdict":{"type":["string","null"]},"auth":{"$ref":"#/components/schemas/InboundAuth"},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/Attachment"}},"created_at":{"type":"string","format":"date-time"}}},"InboundAuth":{"type":["object","null"],"description":"What the receiving MTA decided about the sender, on inbound mail. A field rather than a header string, because code deciding whether to trust a message should be reading a value and not a regex. Gate on dmarc: 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 would reject a great deal of real mail. Null on outbound messages and on anything received before this was captured.","properties":{"spf":{"type":"string","enum":["pass","fail","none"]},"dkim":{"type":"string","enum":["pass","fail","none"]},"dmarc":{"type":"string","enum":["pass","fail","none"]}}},"Thread":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"subject":{"type":"string"},"last_message_at":{"type":"string","format":"date-time"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"}}}},"ApiKey":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"scopes":{"type":"array","items":{"type":"string","enum":["mail:send","mail:read","workspace"]}},"last_used_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"Audience":{"type":"object","description":"A named list. contact_count is every membership on it, subscribed_count only the ones a broadcast would go to.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"contact_count":{"type":"integer"},"subscribed_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"}}},"AudienceContact":{"type":"object","description":"One membership: an address on one list. id is the membership id and is what the /contacts/{member} routes take. contact_id points at the workspace-level contact, which the same address can hold on several lists. attributes are the per-list merge fields substituted into {{ first_name }} in a broadcast, and are a different store from the contact attributes at /v1/contacts/{id}/attributes.","properties":{"id":{"type":"string","format":"uuid"},"contact_id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"status":{"type":"string","enum":["subscribed","unsubscribed","pending","cleaned"]},"attributes":{"type":"object","additionalProperties":{"type":"string"}},"subscribed_at":{"type":["string","null"],"format":"date-time"},"unsubscribed_at":{"type":["string","null"],"format":"date-time"}}},"ContactImport":{"type":"object","description":"The JSON form of an import. A bare array of rows is accepted too, as is a single row object.","properties":{"contacts":{"type":"array","items":{"$ref":"#/components/schemas/ContactImportRow"}},"data":{"type":"array","items":{"$ref":"#/components/schemas/ContactImportRow"}},"dry_run":{"type":"boolean"},"mapping":{"type":"object","properties":{"email":{"type":"string"},"status":{"type":"string"},"tags":{"type":"string"},"ignore":{"type":"array","items":{"type":"string"}},"attr":{"type":"object","additionalProperties":{"type":"string"}}}}}},"ContactImportRow":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email"},"email_address":{"type":"string","format":"email","description":"Accepted in place of email."},"attributes":{"type":"object","additionalProperties":{"type":"string"}},"data":{"type":"object","additionalProperties":{"type":"string"},"description":"Accepted in place of attributes."},"status":{"type":"string","enum":["subscribed","unsubscribed","pending","cleaned"]},"subscribed":{"type":"boolean"},"tags":{"oneOf":[{"type":"array","items":{"type":"string"}},{"type":"string","description":"Comma separated."}]}}},"ImportResult":{"type":"object","description":"held_back counts rows that were taken in as cleaned because the address is suppressed. held lists them, so you can see who was left out and why. duplicates counts rows naming somebody already on the list, which were not written and did not add anybody.","properties":{"created":{"type":"integer"},"updated":{"type":"integer"},"unchanged":{"type":"integer"},"duplicates":{"type":"integer"},"tags_added":{"type":"integer"},"imported":{"type":"integer"},"held_back":{"type":"integer"},"skipped":{"type":"integer"},"held":{"type":"array","items":{"type":"object"}},"errors":{"type":"array","items":{"type":"object"}}}},"ImportDryRun":{"type":"object","description":"What the same request would have done. columns shows how each CSV header was read, so a mapping mistake is visible before anything is written.","properties":{"dry_run":{"type":"boolean"},"total":{"type":"integer"},"would_create":{"type":"integer"},"would_update":{"type":"integer"},"would_leave_unchanged":{"type":"integer"},"would_hold_back":{"type":"integer"},"errors":{"type":"array","items":{"type":"object"}},"columns":{"type":["array","null"],"items":{"type":"object"}},"sample":{"type":"array","items":{"type":"object"}}}},"Contact":{"type":"object","description":"A person at the workspace level, independent of any one list.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"given_name":{"type":["string","null"]},"family_name":{"type":["string","null"]},"company_name":{"type":["string","null"]},"job_title":{"type":["string","null"]},"channels":{"type":"array","description":"The addresses and handles this person can be reached on.","items":{"type":"object"}},"updated_at":{"type":"string","format":"date-time"}}},"Segment":{"type":"object","description":"A saved filter. describes is the rule set written out as a sentence, for showing a person what they built. member_count is as of counted_at unless you asked for a live count.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"audience_id":{"type":["string","null"],"format":"uuid"},"match":{"type":"string","enum":["all","any"]},"rules":{"type":"array","items":{"$ref":"#/components/schemas/SegmentRule"}},"describes":{"type":"string","examples":["has tag vip and opened in the last 30 days"]},"member_count":{"type":["integer","null"]},"counted_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"SegmentRule":{"type":"object","description":"One condition. Rules do not nest and a segment holds at most 20. status and joined rules need the segment to have an audience_id, since neither means anything without a list. An attribute ne rule is true when the attribute is absent, and gt and lt compare numerically only when both sides are numbers.","required":["field","op"],"properties":{"field":{"type":"string","enum":["tag","attribute","status","joined","opened","clicked"]},"op":{"type":"string","enum":["has","not_has","eq","ne","contains","starts_with","gt","lt","exists","not_exists","before","after","within_days","not_within_days","ever","never"]},"name":{"type":"string","description":"The attribute name, on an attribute rule."},"value":{"description":"The tag, attribute value, status, or ISO date the rule compares against."},"days":{"type":"integer","minimum":1,"maximum":3650,"description":"On an opened or clicked rule with within_days or not_within_days."}}},"Broadcast":{"type":"object","description":"One send to a whole audience. stats is null until it starts sending.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"status":{"type":"string","enum":["draft","scheduled","sending","sent","cancelled","failed"]},"audience_id":{"type":["string","null"],"format":"uuid"},"topic_id":{"type":["string","null"],"format":"uuid"},"from":{"type":"string"},"reply_to":{"type":["string","null"]},"subject":{"type":["string","null"]},"track_opens":{"type":"boolean"},"track_clicks":{"type":"boolean"},"scheduled_at":{"type":["string","null"],"format":"date-time"},"sent_at":{"type":["string","null"],"format":"date-time"},"failure_reason":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"stats":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/BroadcastStats"}]}}},"BroadcastStats":{"type":"object","description":"skipped counts addresses that were left out at send time, as suppressed, unsubscribed, or opted out of the topic. Open counts are undercounts wherever the recipient blocks images.","properties":{"recipients":{"type":"integer"},"sent":{"type":"integer"},"delivered":{"type":"integer"},"bounced":{"type":"integer"},"complained":{"type":"integer"},"failed":{"type":"integer"},"skipped":{"type":"integer"},"unique_opens":{"type":"integer"},"unique_clicks":{"type":"integer"},"total_opens":{"type":"integer"},"total_clicks":{"type":"integer"},"unsubscribed":{"type":"integer"}}},"Topic":{"type":"object","description":"A subject a recipient can opt out of on its own. A required topic is one nobody can opt out of, for mail like a password reset or a security notice.","properties":{"id":{"type":"string","format":"uuid"},"key":{"type":"string","examples":["product-updates"]},"name":{"type":"string"},"description":{"type":["string","null"]},"default_opt_in":{"type":"boolean"},"required":{"type":"boolean"},"archived_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"TemplateVariables":{"type":"array","description":"The variables the body uses. An entry is a bare name, or an object that also carries a default and whether it is required. A bad entry answers 400 variables_invalid.","items":{"oneOf":[{"type":"string"},{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"default":{"type":"string"},"required":{"type":"boolean"}}}]}},"Template":{"type":"object","description":"A versioned email body. sendable is true once a version is published; latest_version is the newest one, published or not.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"slug":{"type":["string","null"]},"description":{"type":["string","null"]},"published_version_id":{"type":["string","null"],"format":"uuid"},"latest_version":{"type":["integer","null"]},"sendable":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"TemplateVersion":{"type":"object","description":"One immutable revision. The only syntax in subject, html and text is {{ name }}: no conditionals, no loops, no filters. A value comes from what you supply, then the declared default, and a name with neither fails the send.","properties":{"id":{"type":"string","format":"uuid"},"version":{"type":"integer"},"subject":{"type":"string"},"html":{"type":["string","null"]},"text":{"type":["string","null"]},"variables":{"$ref":"#/components/schemas/TemplateVariables"},"published_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"Automation":{"type":"object","description":"A YAML document that runs steps for one contact at a time. reentry says what happens when the same contact qualifies again: once means never twice.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"slug":{"type":["string","null"]},"description":{"type":["string","null"]},"trigger":{"type":"string","enum":["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","date.attribute","schedule.recurring","api.call"]},"enabled":{"type":"boolean"},"reentry":{"type":"string","enum":["once","re_enter","always"]},"version":{"type":"integer"},"last_run_at":{"type":["string","null"],"format":"date-time"},"last_error":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"AutomationRun":{"type":"object","description":"One contact moving through one automation. resume_at is when a waiting run will pick up again.","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":["string","null"],"format":"email"},"contact_id":{"type":["string","null"],"format":"uuid"},"status":{"type":"string","enum":["running","waiting","completed","stopped","failed","cancelled"]},"version_id":{"type":["string","null"],"format":"uuid"},"started_at":{"type":"string","format":"date-time"},"finished_at":{"type":["string","null"],"format":"date-time"},"resume_at":{"type":["string","null"],"format":"date-time"},"steps_executed":{"type":"integer"},"emails_sent":{"type":"integer"},"error":{"type":["string","null"]}}},"TrackingDomain":{"type":"object","description":"The CNAME that puts your own hostname in front of tracked links and open pixels. fallback_host is where links point when there is no tracking host, and it is said plainly because the fallback is silent by design: a tracking domain that stops resolving must never be a send failure.","properties":{"domain_id":{"type":"string","format":"uuid"},"domain":{"type":"string","example":"acme.com"},"host":{"type":"string","example":"click.acme.com"},"verified":{"type":"boolean"},"verified_at":{"type":["string","null"],"format":"date-time"},"record":{"type":"object","description":"The row to publish. Exactly one CNAME.","properties":{"type":{"type":"string","example":"CNAME"},"name":{"type":"string","example":"click.acme.com"},"value":{"type":"string"},"ttl":{"type":"integer"}}},"fallback_host":{"type":"string","example":"emails.sh"}}},"DedicatedIp":{"type":"object","description":"One dedicated sending address. mail_class is read off the pool it sits in. warmup_day walks a 17 day schedule of daily caps, 50 on the first day and 1000000 on the last; daily_cap is the cap in force today, which is your own lower value when you set one.","properties":{"id":{"type":"string","format":"uuid"},"ip":{"type":"string","examples":["203.0.113.7"]},"region":{"type":"string","enum":["us","eu"]},"region_label":{"type":"string"},"mail_class":{"type":["string","null"],"enum":["transactional","marketing",null]},"pool_name":{"type":["string","null"]},"warmup_state":{"type":"string","enum":["pending","warming","ready","paused","retired"]},"warmup_day":{"type":["integer","null"]},"daily_cap":{"type":["integer","null"]},"sent_today":{"type":"integer"},"day":{"type":"string","format":"date"},"assigned_at":{"type":["string","null"],"format":"date-time"},"warmup_started_at":{"type":["string","null"],"format":"date-time"},"warmup_completed_at":{"type":["string","null"],"format":"date-time"}}},"AnalyticsMetrics":{"type":"object","description":"The same counters everywhere they appear: in totals, in each series period, and in each breakdown row. Rates are fractions between 0 and 1, and are null rather than 0 when nothing was sent. clicks_tracked and opens_tracked are how many of the sent emails carried tracking at all, which is what makes clicked and opened readable.","properties":{"sent":{"type":"integer"},"queued":{"type":"integer"},"rejected":{"type":"integer"},"in_flight":{"type":"integer"},"delivered":{"type":"integer"},"bounced":{"type":"integer"},"complained":{"type":"integer"},"delivered_rate":{"type":["number","null"]},"bounce_rate":{"type":["number","null"]},"complaint_rate":{"type":["number","null"]},"clicks_tracked":{"type":"integer"},"clicked":{"type":"integer"},"clicks":{"type":"integer"},"click_rate":{"type":["number","null"]},"opens_tracked":{"type":"integer"},"opened":{"type":"integer"},"opens":{"type":"integer"}}},"AnalyticsReport":{"type":"object","properties":{"range":{"type":"object","properties":{"from":{"type":"string","format":"date"},"to":{"type":"string","format":"date"},"group_by":{"type":"string","enum":["day","week","month"]}}},"totals":{"$ref":"#/components/schemas/AnalyticsMetrics"},"series":{"type":"array","items":{"allOf":[{"type":"object","properties":{"period":{"type":"string","format":"date"}}},{"$ref":"#/components/schemas/AnalyticsMetrics"}]}},"breakdown":{"type":["object","null"],"properties":{"by":{"type":"string","enum":["domain","tag","mail_class","template"]},"tag_key":{"type":["string","null"]},"rows":{"type":"array","items":{"allOf":[{"type":"object","properties":{"key":{"type":["string","null"],"description":"Null on the folded tail row."},"label":{"type":"string"}}},{"$ref":"#/components/schemas/AnalyticsMetrics"}]}}}},"notes":{"type":"object","description":"Sentences about how these numbers were counted, safe to show next to them.","properties":{"opens":{"type":"string"},"tracking":{"type":"string"},"sources":{"type":"string"}}}}}}}}