Every change customers can see, in the order it happened. Where something altered behaviour you were relying on, it says so — and where it changed nothing for existing keys, it says that too, because that is usually the first question.
There was no reference until now, and the honest reason was that a document describing an API tends to drift away from the API until a reader cannot tell which half is wrong. The one now published covers every /v1 endpoint with the scope each needs, the error envelope with every code it can carry, idempotency, the published limits, the send request field by field, and what each message status means — in English and French.
It is checked against the router on every deploy, in both directions: an endpoint that exists with no entry fails the build, and so does an entry for an endpoint that has been removed. The second is the one that matters to you, because a documented endpoint that answers 404 sends you looking at your own request first.
Webhook payloads, signature verification and the retry schedule stay on the Webhooks page inside your account, where the examples can name your own endpoints.
Changed
The webhook verification example named a field that does not exist#
The signature-verification example on your Account page told you to read $event['type']. There is no type field — it is event. Anyone who copied that example got null and had to work out why, from our documentation rather than in spite of it. It is corrected, and it was checked against real production deliveries rather than against the example itself.
Two more things it was not saying. The event list omitted blocked, opened and clicked. And unsubscribed is shaped differently from the rest: message_id is null, there is no top-level email, and the address is under data.email — so a handler written from the old sample would have read an empty address on precisely the event that must never be missed.
The example now also shows what to do about replay, which is the pair of fields people ask about. The signature header carries t=, the Unix time the request was signed, and it is inside the signed string — so reject anything whose t is more than a few minutes old; the sample uses 300 seconds. Then de-duplicate on id, the delivery’s uuid, which is the same on every retry. Together those two make a replayed delivery indistinguishable from a retry of it, and a retry you have already processed is a no-op.
October 2026
New
Webhook deliveries now carry a stable id for de-duplication#
Every webhook delivery now carries its own identifier: id in the JSON body, repeated as an X-SES-Delivery-Id header. It is a UUID, and it is the same on every retry of that delivery — so you can store it and safely ignore anything you have already processed.
Retries always re-sent the body byte-identically, so the body itself was usable as a de-duplication key, but nothing in the payload said so. Now there is a field for it. Read it from the body rather than the header when it matters: the signature covers the body, and the header sits outside it.
The retry schedule is documented on the Webhooks page as well: up to seven attempts — immediately, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours. Any 2xx stops it. Nothing else about the payload changed.
Changed
Transactional mail no longer advertises one-click unsubscribe#
A transactional key already added no unsubscribe footer. It was still sending the List-Unsubscribe headers, which is what draws the native unsubscribe button in Gmail and Apple Mail — and that button takes no confirmation. A recipient who used it on a receipt was opted out of every receipt from you, with nothing on screen telling them so. Those headers are now withheld on the transactional stream. Broadcast and older keys without a stream are unchanged.
If you want the button on transactional mail, turn on key-scoped unsubscribe for that key: the headers come back and an opt-out reaches only that key’s mail. The {{unsubscribe_url}} merge tag was never affected and still works on either stream — it lands on a page that says what it will do before it does it.
Every message now also carries a List-Help header (RFC 2369) pointing at a page for your recipients: who sent the message, why only you can say how their address reached you, how to stop further mail on that stream, and how to report mail they never agreed to. It names your sending domain and nothing about the recipient — no address, no token, no message id.
New
Your own headers, on the message and back on the webhook#
Send up to ten headers of your own with a message — a survey id, an invoice number, a case reference — and they travel on the message and come back on every webhook event for it. Names are checked against a published reserved list, and anything that would overwrite one of ours, or carry a line break, fails the send with a 422 naming the header rather than being quietly stripped. If nothing at the receiving end needs to read the value, tags still does the same job and keeps it private.
List-Unsubscribe and List-Unsubscribe-Post are now inside the DKIM signature on both sending paths, not merely present on the message. RFC 8058 asks for that, and a receiver who checks will see them covered by the h= list. Nothing changes on your side, and nothing already sent was affected.
A public FAQ at /faq, answered in English and French: what we hold and what we do not, where the data lives, what happens to a suspended account, how suppression works, and which stream a key should be on. Written from questions customers actually asked, which is why some of the answers are less flattering than a sales page would allow. It grows as questions arrive rather than being finished.
A new API key is created as Send and diagnose: it can send, and it can read back what happened — delivery status, bounces, suppressions, which DNS record a domain is missing. It still cannot suppress an address, create a webhook endpoint, add a sending domain or touch your team. Existing keys keep the scope they were made with; rotate one to pick up the new default.
Pass an idempotency_key of your own on a send and a retry stops being a risk. An identical retry within 24 hours returns the first response again, with HTTP 200 and an Idempotent-Replay: true header, and no second message goes out. A retry while the first is still running answers 409; the same key with a different message is refused with 422 rather than quietly handing back the first message's id. A send we reject releases its key, so a bad address is something you fix and retry with the same key. Without a key, nothing changes.
An endpoint can be bound to a single key, so staging events reach your staging receiver and production reaches production. Leave it on All keys and it behaves as it always has — every endpoint that existed before this is unbound. Every event we can attribute to a key now carries api_key_id as well, so one shared endpoint can route on it instead.
Zedmail's MCP server gained the diagnostic tools: check a domain's DNS, find out why an address is not receiving, read bounce and complaint rates, list and test webhook endpoints. An assistant can now answer “why did that bounce” without anyone opening a ticket. A key only ever sees the tools its own scopes permit.
Section 7 of the terms now commits to at least 90 days' written notice, with the service running normally throughout, nothing billed past the stop date, unused prepaid credit refunded, and your sending data, delivery logs and suppression lists exportable the whole time. A municipal customer asked for it in writing, which was a fair thing to want and something we should have stated already.
Somebody declining your newsletter should not thereby decline their own password resets. On a transactional key the unsubscribe can be narrowed to that key alone, instead of the whole account. Spam complaints and hard bounces stay account-wide, deliberately: a complaint is a statement about you and that recipient, and a dead mailbox is dead for every key you own.
If sending is ever held on your account — a bounce or complaint rate over threshold, say — you are emailed at once with the reason. Until this, the only notice an account got was its own next API call failing.
/email-check reads any domain's SPF, DKIM and DMARC the way a receiver does and says what is wrong with it. Free, no account, and it works for domains that have nothing to do with us.
The service-wide suppression list is stored hashed rather than in the clear. Service-wide entries persist while any account still suppresses the address, on a three-year clock, and legal holds are permanent.
Drafted with counsel and published at /dpa.php rather than produced on request. It forms part of the terms and is accepted with them; we will execute a signed copy if your process needs one on file.
Up to ten files and 10 MB per message. We do not keep them — the bytes are deleted as the message leaves us, and only the filename, type and size stay on the record.
Set per stream, so your transactional logs and your campaign logs need not keep the same schedule. When the window passes the whole record goes together: recipient, subject, body and delivery status. Seven days is the floor.
Every API key you create is now one or the other, and the choice changes behaviour rather than labelling it. Transactional sends no unsubscribe footer — nobody should be able to opt out of their own password reset. Broadcast always adds one, in your recipient's language. The two also keep their reputation apart, so a campaign having a bad week does not follow your receipts into the spam folder. Existing keys are unchanged.
Separate IP pools, warmed deliberately over months, so that one sender's list problem stops being everyone's deliverability problem. The warming moves a measured fraction of real traffic rather than moving a sender, because receivers judge a new address on the shape of its volume.
Per key, you can demand that a message is encrypted to a server that proves its identity. If the receiver cannot, the message is held rather than sent in the clear, and we tell you which recipients were affected — and they are not unsubscribed for it. For the major consumer providers it is enforced regardless, because they publish policies saying their servers should never accept unencrypted mail.
A user-initiated GDPR or Law 25 erasure is now its own terminal state rather than a suspension that looks like one, so reporting, support and every access gate can tell the two apart.
Nightly encrypted backups to Québec object storage, with the decryption key held offline so a compromised server cannot read them. Freshness, size and checksum are verified every night, and a full decrypt-and-restore is tested on a schedule. A backup nobody has restored is a hope, not a control.
A reversible, send-only hold. An account under review stops sending but keeps its dashboard, its keys and its data — which is the difference between a question and a punishment.
/domain-setup, written for the person who has been handed a registrar login and a list of records, and linked from the dashboard where that person actually is.
Every timestamp in the dashboard renders in the timezone on your account. It had been collected, validated and stored for years while every date on screen was still UTC.
The dashboard, the public pages, the account emails and the unsubscribe and feedback pages your recipients see. Subscriber-facing pages negotiate the language from the browser, because a recipient has no account with us to carry a preference.
Canadian dollars, pounds, euros or US dollars, chosen at signup, with their own price points rather than a conversion. Invoices keep the currency they were issued in.
Smaller fixes are not listed. Deliverability work mostly is not either, because it is continuous rather than datable — but when it changes something you can observe, like a sending address or a bounce you would otherwise have seen, it appears here.