A JSON API over HTTPS. 32 endpoints, all versioned under /v1. Last updated November 1, 2026.
Every request carries an API key as a bearer token. Keys are created under API keys and shown once.
Authorization: Bearer zm_live_...
A key carries a set of scopes, and a request outside them is refused with 403 insufficient_scope naming the scope it wanted.
| Scope | Allows |
|---|---|
email:messages.send | Send mail. |
email:messages.read | Read a message or a recipient. |
email:domains.read | List and inspect sending domains. |
email:domains.write | Add, verify and remove domains. |
email:suppressions.read | List suppressions. |
email:suppressions.write | Add, remove and import suppressions. |
email:stats.read | Statistics, quota and sending state. |
email:webhooks.read | List webhook endpoints. |
email:webhooks.write | Create, change, delete and test endpoints. |
email:sandbox.write | Add a verified test recipient while in sandbox. |
iam:users.read | List team members and invitations. |
iam:users.write | Invite, change a role, remove a member. |
403 and should not conclude scopes are unenforced. Create a new key to get a scoped one.An error is JSON with a stable machine code and a sentence for a human. Match on error; message may be reworded or translated.
{
"error": "insufficient_scope",
"message": "This API key lacks the email:messages.send scope."
}
| Status | Means |
|---|---|
| 200 | Success. |
| 201 | Created — a domain, suppression, webhook endpoint or invitation. |
| 401 | Missing, malformed or unknown Authorization header. |
| 403 | insufficient_scope — the key is valid but lacks the scope. |
| 404 | not_found — no such message, domain, endpoint or user on this account. |
| 422 | The request was understood and refused. See the error code. |
| 429 | Rate limited, or the account's daily send limit is reached. |
| 500 | Our fault. Safe to retry. |
Codes you may see on a 422 include invalid_from, invalid_email, invalid_domain, invalid_url, subject_too_long, body_too_large, file_too_large, reserved_header, too_many_headers and duplicate_header. A few older validation failures return error as a sentence with no code; treat an unrecognised error value as a refusal you should not retry unchanged.
List-Unsubscribe, Content-Type, From and anything beginning X-Zedmail-. A carriage return or line feed in a header value is also refused; we never strip and send.Send an Idempotency-Key header and a retry of the same request will not send a second message. Keys are remembered for 24 hours.
Idempotency-Key: order-18472-receipt
It is a guard against sending twice, not a cache of responses: a request that arrives while the first is still in flight is refused rather than answered from a half-finished send.
| Limit | Value |
|---|---|
| Subject | 998 bytes |
| HTML body | 1 MB |
| Text body | 1 MB |
| Messages per batch | 50 |
| Custom headers per message | 10 |
| Custom header name | 64 bytes |
| Custom header value | 200 bytes |
| All custom headers together | 2048 bytes |
One address per request. A 2xx means we have accepted the message and taken responsibility for it, not that it has arrived — poll the message or take a webhook for that.
| Method | Path | Scope | Does |
|---|---|---|---|
| POST | /v1/email/send |
email:messages.send |
Send one message. |
| POST | /v1/email/batch |
email:messages.send |
Up to 50 messages in one request. Returns a results array in the order sent; one message failing validation does not fail the others. |
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | yes | Recipient address. One per request; use /v1/email/batch for many. |
subject |
string | — | Up to 998 bytes. |
html |
string | — | HTML body. At least one of html or text is required. |
text |
string | — | Plain-text body. Generated from html if omitted. |
reply_to |
string | — | Reply-To address. |
tags |
array | — | Your own labels, returned on webhooks and in statistics. Private — never put on the wire. |
headers |
object | — | Custom headers, name/value. Reserved names are refused with 422 rather than dropped silently. |
attachments |
array | — | Objects with filename, content_type, base64 content, and optionally cid for inline images. |
language |
string | — | en or fr. Sets the language of any footer we add. |
The from address is set on the key, not in the request, so a leaked key cannot send as somebody else.
# Send one curl -X POST https://zedmail.com/v1/email/send \ -H "Authorization: Bearer $ZEDMAIL_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-18472-receipt" \ -d '{ "to": "jane@example.com", "subject": "Your receipt", "html": "<p>Thank you.</p>", "tags": ["receipt"] }' => 200 { "status": "queued", "message_id": "msg_4f68af67910b67a097" }
/v1/email/{id} answers "what happened to this message". /v1/recipients/{email} answers "did this person get it", which the first cannot, because the asker rarely has a message id.
| Method | Path | Scope | Does |
|---|---|---|---|
| GET | /v1/email/{id} |
email:messages.read |
One message: status, timestamps, bounce detail if any. |
| GET | /v1/recipients/{email} |
email:messages.read |
Everything known about one address, newest first. limit and offset query parameters. |
| Status | Means |
|---|---|
queued | Accepted and waiting to be handed to the mail server. Seconds — easily missed in testing. |
sent | Handed over, awaiting an outcome. The normal in-flight state and the one you will see most. Usually resolved in seconds, but a recipient server that keeps deferring can hold a message here for up to three days — do not treat sent as a failure. |
delivered | The receiving server accepted it. Final. |
bounced | Refused as undeliverable; the recipient is suppressed. Final. |
blocked | Not sent, because transport encryption could not be met. The address stays valid and is never suppressed. Final, but you may retry once their server is fixed. |
complained | The recipient marked it as spam; they are suppressed. Final. |
Only the last four are final. blocked is the one people miss: the message was not sent because transport encryption could not be established, so the address is still good.
A suppressed address is refused before a send is attempted. Reasons are bounce, complaint, unsubscribe, manual and soft_bounce_threshold. A blocked message never creates a suppression — the address is still valid.
| Method | Path | Scope | Does |
|---|---|---|---|
| GET | /v1/suppressions |
email:suppressions.read |
List. reason, page, per_page query parameters. |
| POST | /v1/suppressions |
email:suppressions.write |
Add one. email and reason. |
| DELETE | /v1/suppressions/{email} |
email:suppressions.write |
Remove one. Complaints and some locked rows are operator-only and will not be removed. |
| POST | /v1/suppressions/import |
email:suppressions.write |
Bulk import. |
These manage endpoints. The payloads, the signature scheme and the retry schedule are documented on the Webhooks page in your account, where the examples can name your own endpoints and secret.
| Method | Path | Scope | Does |
|---|---|---|---|
| GET | /v1/webhooks |
email:webhooks.read |
List endpoints. |
| POST | /v1/webhooks |
email:webhooks.write |
Create one. url (HTTPS) and events. |
| PUT | /v1/webhooks/{id} |
email:webhooks.write |
Change url, events or is_active. |
| DELETE | /v1/webhooks/{id} |
email:webhooks.write |
Delete one. |
| POST | /v1/webhooks/{id}/test |
email:webhooks.write |
Send a test delivery to that endpoint. |
You may only send from a domain you have verified. Verification checks DKIM, SPF and DMARC as a receiver reads them.
| Method | Path | Scope | Does |
|---|---|---|---|
| GET | /v1/domains |
email:domains.read |
List domains and their verification state. |
| POST | /v1/domains |
email:domains.write |
Add one. domain. Returns the DNS records to publish. |
| GET | /v1/domains/{id} |
email:domains.read |
One domain, with each record and whether it currently resolves. |
| POST | /v1/domains/{id}/verify |
email:domains.write |
Re-check DNS now. |
| DELETE | /v1/domains/{id} |
email:domains.write |
Remove one. |
from and to query parameters take YYYY-MM-DD. The three /v1/account/* routes mirror the shape of Amazon SES's equivalents, so a migration can keep its existing calls.
| Method | Path | Scope | Does |
|---|---|---|---|
| GET | /v1/stats |
email:stats.read |
Sent, delivered, bounced, complained over a period. |
| GET | /v1/stats/bounces |
email:stats.read |
Bounces, with type and code. |
| GET | /v1/stats/complaints |
email:stats.read |
Complaints. |
| GET | /v1/account/quota |
email:stats.read |
Daily send limit and what is left of it. |
| GET | /v1/account/sending |
email:stats.read |
Whether sending is enabled, paused or in sandbox. |
| GET | /v1/account/statistics |
email:stats.read |
Rolling counts. |
Invitations and members. The /invites routes are declared before /{id} and ids are UUID-constrained, so the literal invites segment can never be read as a user id.
| Method | Path | Scope | Does |
|---|---|---|---|
| GET | /v1/users |
iam:users.read |
List members. |
| GET | /v1/users/{id} |
iam:users.read |
One member. |
| PATCH | /v1/users/{id} |
iam:users.write |
Change a role. role. |
| DELETE | /v1/users/{id} |
iam:users.write |
Remove a member. |
| GET | /v1/users/invites |
iam:users.read |
List open invitations. |
| POST | /v1/users/invites |
iam:users.write |
Invite someone. email and role. |
| DELETE | /v1/users/invites/{id} |
iam:users.write |
Revoke an invitation. |
A new account sends only to addresses it has verified. This adds one.
| Method | Path | Scope | Does |
|---|---|---|---|
| POST | /v1/sandbox/recipients |
email:sandbox.write |
Add a test recipient. email. They are sent a confirmation. |
/mcp — a JSON-RPC wrapper over these endpoints for agents, filtered by the same scopes.Something missing or wrong here? Tell us and it gets fixed — this page is checked against the router on every deploy, but only the code can say what it does.