API reference

A JSON API over HTTPS. 32 endpoints, all versioned under /v1. Last updated November 1, 2026.

Authentication

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.

ScopeAllows
email:messages.sendSend mail.
email:messages.readRead a message or a recipient.
email:domains.readList and inspect sending domains.
email:domains.writeAdd, verify and remove domains.
email:suppressions.readList suppressions.
email:suppressions.writeAdd, remove and import suppressions.
email:stats.readStatistics, quota and sending state.
email:webhooks.readList webhook endpoints.
email:webhooks.writeCreate, change, delete and test endpoints.
email:sandbox.writeAdd a verified test recipient while in sandbox.
iam:users.readList team members and invitations.
iam:users.writeInvite, change a role, remove a member.
Keys created before scopes existed have full access. They were grandfathered rather than broken, so if you hold an older key you will never see a 403 and should not conclude scopes are unenforced. Create a new key to get a scoped one.

Errors

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."
}
StatusMeans
200Success.
201Created — a domain, suppression, webhook endpoint or invitation.
401Missing, malformed or unknown Authorization header.
403insufficient_scope — the key is valid but lacks the scope.
404not_found — no such message, domain, endpoint or user on this account.
422The request was understood and refused. See the error code.
429Rate limited, or the account's daily send limit is reached.
500Our 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.

A header we set ourselves cannot be overridden, and an attempt is refused rather than silently dropped — including 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.

Idempotency

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.

Limits

LimitValue
Subject998 bytes
HTML body1 MB
Text body1 MB
Messages per batch50
Custom headers per message10
Custom header name64 bytes
Custom header value200 bytes
All custom headers together2048 bytes

Sending

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.

Request fields

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" }

Message status

/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.

Statuses

StatusMeans
queuedAccepted and waiting to be handed to the mail server. Seconds — easily missed in testing.
sentHanded 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.
deliveredThe receiving server accepted it. Final.
bouncedRefused as undeliverable; the recipient is suppressed. Final.
blockedNot 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.
complainedThe 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.

Suppressions

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.

Webhooks

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.

Sending domains

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.

Statistics and account

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.

Team

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.

Sandbox

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.

Elsewhere

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.