Skip to content
Docs menu

Changelog

Changes to the zSender API that you would have to act on, newest first. Each date is the day the change reached production.

2026-09-22

zsender-sdk@0.2.0: an unusable 2xx is a typed error#

Breaking

^0.1.0 does not reach 0.2.0; every consumer needs an explicit bump

If your package.json, lockfile, or workspace catalog pins zsender-sdk as ^0.1.0, a plain npm install will not pick this release up. A caret on a 0.x version allows patch releases only (^0.1.0 is satisfied by 0.1.1 but not 0.2.0), so receiving this version means editing the pin yourself, not just reinstalling.

Fixed

a 2xx the client can't use is now ZsenderResponseError, never a raw SyntaxError/TypeError

Every method handed a 2xx response straight to JSON.parse (or, on the two binary routes, straight back to you) with no check that the response was actually a zSender response. A proxy's HTML, an empty body, or JSON that didn't match the endpoint's declared shape reached you as an unhandled SyntaxError or TypeError, outside the SDK's own error contract, so a catch (err) { if (err instanceof ZsenderError) ... } block never caught it. The worst case didn't even throw: emails.received.raw() against a 200 text/html (a captive portal, an intermediary's error page) resolved successfully, handing back that page as if it were the customer's raw mail.

Every genuine zSender response carries X-Request-Id, which the API sets on every response; a 2xx without it is now ZsenderResponseError too. That is proof the response didn't come from the API at all, not just an unparseable one.

Not usable from a browser. Requiring that header makes it explicit: x-request-id is not a CORS-safelisted response header and the API exposes no custom ones, so browser JavaScript reads it as absent and every call fails. The SDK targets servers and edge runtimes, as it always documented; a Cloudflare Worker is unaffected, its fetch is not CORS-constrained. Call zSender from your own backend, where your API key isn't readable by every visitor anyway.

What to act on: a third error class, ZsenderResponseError, alongside ZsenderError and ZsenderConnectionError. It carries the same meaning as ZsenderConnectionError (your request's outcome is unknown, not refused) and the same idempotencyKey field when the call had one: retrying with that key lets the API replay a completed send or perform an absent one, safely either way. ZsenderError stays what it always was, a refusal the server told you about, with one exception worth knowing: a non-2xx whose body isn't the error contract at all (a load balancer's 502 text/html) still arrives as ZsenderError with code: 'internal_error', and nothing in such a response says whether it was generated before your request reached the API or after. Treat a 5xx with that slug as "may or may not have happened", not as a refusal; it carries no idempotency key. Full recovery rule: Errors and retries.

Changed

where emails.send() retries a ZsenderResponseError, and where it cannot

emails.send() always carries an idempotency key, but which of two moments the error came from decides whether the SDK retries it for you:

  • Raised while reading the response: a body that isn't JSON, a body that isn't an object, a missing X-Request-Id. That happens inside the request, so it is now retried under the SDK's existing unknown-outcome rule (the one that already covered ZsenderConnectionError) instead of being rethrown immediately as it was in 0.1.0. The retry replays the same key, so it cannot turn into a duplicate send.
  • Raised by a translator afterwards: the body parsed as an object, but a field the client reads is missing. That is detected after the response was read, past the retry loop, so it is thrown on the spot and never retried, in any mode.

What to act on: the second case is exactly why the error carries idempotencyKey. The request went out with that key, so the send may well have happened. Retry with err.idempotencyKey, never a fresh one: minting a new key there is what produces the duplicate email.

Changed

an unrecognised error slug reaches you intact instead of collapsing to internal_error

ZsenderError.code is now typed ApiErrorCode | (string & {}). The server's error contract can grow a slug this SDK version predates; that slug now reaches error.code with the server's own message, instead of being flattened to the generic internal_error with a message that discarded what the server actually said. This is a deliberate trade against compile-time exhaustiveness: code is no longer a closed union, so a switch (error.code) needs a default arm. It will not fail to compile just because a slug is unhandled, on this version or any future one. (An earlier note in this file, on the domain_blocked entry, said the opposite; that was never true of any published version and is corrected there now.)

Also shipping in this version

  • domain_blocked is now in API_ERROR_CODES; see the 2026-09-21 entry ("a domain can be blocked platform-wide") for what the slug itself means. Recognizing it was already possible in principle within this same release, the moment the passthrough section above it landed (any slug reaches error.code with its message intact), though not on 0.1.0, where an unrecognized slug does neither; but 0.2.0 is the first version where it's also a value your editor autocompletes.
  • A doc-comment fix: SendEmailInput's JSDoc on https+notrack:// now matches what the 2026-09-15 entry actually fixed in the API: the marker is safe on every sending domain, not only one that already has click tracking on. The comment had been left describing the old, narrower behaviour.

2026-09-21

a domain can be blocked platform-wide#

Breaking

a sixteenth error slug, domain_blocked

A domain name can now be banned across every account, by a zSender operator. A banned name cannot be added, verified, enabled, or sent from (over HTTP or the SMTP relay, in live or test mode), and mail addressed to it is dropped, not stored. Blocking a name also covers every subdomain of it (banned.example blocks send.banned.example, never notbanned.example).

What to act on: a new 403 slug, domain_blocked. Treat it as permanent and non-retryable (unlike domain_not_verified, there is no DNS fix), and contact support if you believe it's a mistake. On the SMTP relay the reply is a permanent 550.

This is an operator action, not something your own account triggers by mistake: an ordinary integration only sees it if the domain it sends from has been banned platform-wide, most often because the account using that name was suspended for abuse. Unsuspending an account does not lift a block on its domains; that stays a separate, explicit action.

The TypeScript SDK recognizes this slug as of zsender-sdk@0.2.0 (see the 2026-09-22 entry). On the published 0.1.0 it is not recognized at all: that version admits only the fifteen slugs it shipped with, so a domain_blocked 403 arrives as code: 'internal_error' with the server's explanation replaced by a generic Unexpected response from the zSender API (HTTP 403). If you are on 0.1.0 and seeing an unexplained internal_error on a 403, this is the likely cause; upgrading is what makes the slug and its message visible at all.

2026-09-20

from accepts a display name#

Added

a display name in from

from now accepts "Name <address>" as well as a bare address, e.g. "Example <hello@example.com>". Nothing that worked before stops working: a bare address is still accepted exactly as it always was, and the address half is still validated with the same rule it always faced.

This closes a divergence between the two send paths: the SMTP relay has always accepted a display name in From (it relays the raw MIME and only ever needed the bare address for the domain-ownership check), while POST /v1/emails/send answered 400 for the same input. One parser now backs both.

2026-09-19

GET /v1/emails?search= ignores case, and % no longer matches everything#

Fixed

a search that should have matched now does

If you filter GET /v1/emails with search, queries that previously returned nothing may now return rows. Nothing you send changes; the filter simply answers correctly. Three separate defects, all in how the query was built:

  • Case. The subject and sender match was case-sensitive, so ?search=Weekly missed a subject reading weekly digest. Both are now case-insensitive.
  • Recipients. search compares a whole recipient address, and that comparison could not be satisfied by a differently-capitalised query: ?search=Ali@Example.com returned nothing for mail sent to ali@example.com. The address in your query is now normalized the same way stored recipients already are, so capitalisation no longer matters. (Recipient matching is still a whole-address match, not a substring: ?search=example.com does not list every message to that domain. That was always true; it was simply never written down.)
  • % and _ were wildcards. They were interpolated into the underlying LIKE pattern without escaping, so ?search=% matched every message and ?search=_ matched every message whose subject or sender was at least one character. Both are now matched literally. If you were relying on ?search=% to list everything, drop the parameter instead; it was never a documented wildcard.

The documented description of search was also wrong in two ways and has been corrected: it named only "subject or recipient" while the sender was matched too, and it did not say that the recipient match is a whole address while the other two are substrings. The same correction reaches openapi.json, the TypeScript SDK's types and zsender.io/docs/api/emails.

2026-09-15

https+notrack:// now works on every sending domain#

Fixed

https+notrack:// now works on every sending domain

If you have been using https+notrack:// on a domain with click tracking off, your recipients were receiving that literal scheme and the link could not be opened. It is fixed, but check whether any already-sent mail is affected; nothing surfaced it at the time.

The scheme was documented as a per-link way to keep one URL from being rewritten, alongside the per-domain open_tracking / click_tracking switches, without saying that the first depended on the second. It did. The +notrack was removed on the same pass that rewrites links, so it ran only when that pass ran; with click_tracking: false (the default) nothing removed it and the recipient got https+notrack://... verbatim. Nothing signalled it: 200 on send, delivered, DKIM and SPF passing. The first symptom was a human clicking a link that did nothing, which made single-use links (magic links, password resets, invitations) both the most tempting and the worst place to hit it.

What changes for you. Write https+notrack:// whenever a URL must not be rewritten. You no longer need to know, or look up, the sending domain's tracking state: the marker is removed on the way out in both states, so the scheme means one thing. Turning click tracking on or off later does not change what you have to write.

One exception, on the SMTP relay only. That transport hands your MIME to the mail server byte for byte and never decodes it, so a marker inside a base64-encoded part cannot be removed and still ships literally. Quoted-printable and plain 7bit/8bit parts are handled, including a marker that quoted-printable split across a soft line break, unless the encoder also escaped characters inside the marker itself (=3A for :), which common encoders do not emit. If your mail library base64-encodes HTML bodies and you need an unrewritten link, write a plain https:// URL: with click tracking off, and for as long as it stays off, nothing rewrites it anyway. The JSON send API's html and text have no such gap; an HTML attachment is not scanned on either transport.

No change to anything else. Messages without the marker take exactly the path they did before; a plain https:// link on a click-tracking domain is still rewritten as it always was.

2026-09-13

session revocation, and two delivery-behaviour changes#

Added

dashboard sessions are now revocable, and every session is ended by this deploy

Every login and registration now creates a server-side session, and both the access and refresh token name it. This makes revocation possible for the first time, and it takes effect immediately: a revoked or expired session is rejected on its very next request, not once its 15-minute access token happens to expire.

Two new routes: POST /api/auth/logout ends the calling session; POST /api/auth/logout-all ends every session belonging to the signed-in user, including the one making the call. Both require a dashboard JWT and are idempotent.

Refresh tokens now rotate on every use. Presenting an old refresh token more than 30 seconds after it was superseded is treated as replay of a captured token: the answer is 401, and the entire session is revoked, not just that refresh. Every device and tab signed into it must log in again. A refresh within the 30-second window (two tabs refreshing at once, or an ordinary retry) still succeeds and returns the current pair.

What to check. This deploy invalidates every access and refresh token issued before it: they carry no session id and are rejected as unauthorized, so every signed-in user and every open dashboard tab is signed out and must log in again. If your integration stores a zSender dashboard JWT (not an API key; API keys are unaffected) outside the browser and expects it to keep working across a deploy, it will not; treat a 401 from a previously-valid refresh token as a sign that the whole session is gone, not as a transient failure to retry.

Changed

the SMTP relay rejects a send whose recipients are all suppressed

A message relayed through smtp.zsender.io whose every recipient is on your suppression list is now refused with 550 All recipients are suppressed. It used to be accepted with a plain 250 Message accepted for delivery: a success reply for a message that was delivered to nobody, with no way to find out afterwards, since the stored message record lists only the recipients that were actually sent to.

Partial suppression is unchanged: if at least one recipient is deliverable, the message is relayed to the rest and still answers 250. SMTP has no way to report "I dropped two of these", and refusing the whole message to convey it would lose mail that was fine. The HTTP API is also unchanged: POST /v1/emails/send still answers 200 with a per-recipient suppressed array, because a JSON body can carry that detail and a reply code cannot.

What to check. If your application relays through SMTP and sends to lists that may be fully suppressed (a re-engagement campaign, a retry of an old batch), those sends now surface as a permanent failure in your mail library instead of silently succeeding. 550 is permanent: fix the recipient list, don't retry as-is.

Changed

webhook deliveries retry for about seven hours, not two and a half minutes

A failed webhook delivery is now retried with gaps of 1s, 5s, 30s, 2m, 10m, 1h, 6h: eight attempts over 7h12m, up from five attempts over 2m36s. Nothing about what counts as a failure changed: still only a 2xx counts as delivered, and the three permanent cases (endpoint deleted, SSRF-blocked destination, undecryptable secret) still fail on the first attempt with no backoff.

What this changes for you. An endpoint that is down for more than a couple of minutes used to lose every event permanently; a deploy of your own was enough to do it. Those deliveries now catch up on their own. In exchange, a delivery can stay pending for hours instead of minutes, so code that treats "not delivered yet" as "failed" after a short wait will now see pending far longer. Read the delivery's status, and don't infer failure from elapsed time. Manual retry from the dashboard's Deliveries tab is unchanged and still available for the whole 30-day retention window.

The schedule previously declared a fifth gap of 5 minutes that could never be selected: the terminating condition fired on exactly the attempt that would have used it. The attempt count is now derived from the schedule (gaps + 1) rather than maintained beside it, so every declared gap is reachable by construction.

2026-09-13

zsender-sdk@0.1.0 is on npm#

Added

zsender-sdk@0.1.0 published to npm

The TypeScript SDK described in the 2026-09-08 entry below is installable the normal way: npm install zsender-sdk (or bun add zsender-sdk / pnpm add zsender-sdk).

The package is zsender-sdk, not zsender. The bare name is unusable: npm refuses it as too similar to an existing package, and the zsender org name is taken as well, so a @zsender/... scope is not available either. zsender-sdk passes npm's similarity check and still carries the brand for anyone searching npm. The import specifier is the package name, so it is from 'zsender-sdk' everywhere, including zsender-sdk/contract. Nothing else about the package changed to get there: same zero-runtime-dependency build, same camelCase public surface, same wire types checked against the API's own request and response schemas at compile time. The only thing that changed is where it comes from: npm, instead of a repository checkout. See the SDK guide for install/construct/send/verify.

Changed

the API reference lives at zsender.io/docs

The API reference is now the docs site. Nine guide pages (send-email, domains, webhooks, inbound, smtp, test-mode, suppressions, sdk, reliability) plus four more pages (quickstart, authentication, errors, and the /docs/api index) and five generated per-tag reference pages (/docs/api/{account,domains,emails,sandbox,suppressions}) cover the API, generated from and validated against the same OpenAPI document the API serves at GET /openapi.json. Start at zsender.io/docs. One area has no page yet: Billing & Subscription (/api/billing/*) is not part of the schema-driven /v1 + /api/tenants/* surface these pages are generated from, so it has no docs-site page as of this entry; nothing about the /api/billing/* endpoints themselves changed.

2026-09-11

four 403s split out of forbidden#

Breaking

four 403s split out of forbidden, so you can tell them apart

Every rejection that was "your credential is valid but you may not do this" answered the same forbidden slug. Four of them are the ones you would actually act on, and each needs a different action, so telling them apart meant matching on message, which this changelog's own contract says may be reworded at any time.

The HTTP status is unchanged: all four were 403 and still are. What changed is error:

New slugWhenWhat to do
suspendedThe account is suspended. Every authenticated call answers this.Contact support@zsender.io
email_unverifiedNo user on the account has confirmed their email address. Blocks sending, adding a domain, creating an API key.Click the link in the verification email
domain_not_verifiedThe from domain has no verified sending capability.Publish the DNS records, then POST /v1/domains/:id/verify
test_mode_restrictedA test key (zst_...) called a live-only endpoint: analytics, usage, suppression writes, received mail.Use a live key (zs_...)

forbidden still exists and now means what is left: the key lacks a permission, or the resource belongs to another account.

If you branch on error, a check for "forbidden" will stop matching these four cases. If you branch on the HTTP status, nothing changes. The full list is on zsender.io/docs/errors.

The TypeScript SDK's ZsenderError.code is typed from the same list, so an exhaustive switch on it will fail to compile until the four new cases are handled, which is the point.

Superseded on 2026-09-22: from zsender-sdk@0.2.0, ZsenderError.code is no longer a closed union, so an unhandled slug no longer fails to compile; a switch needs a default arm. See that entry.

Fixed

a failed DNS lookup no longer switches your open/click tracking off

Verifying a domain re-checks its track.yourdomain.com CNAME. When that one lookup failed (a resolver timeout, a SERVFAIL) rather than answering, we treated the non-answer as "the record is gone" and removed the tracking configuration from the mail server. Opens stopped being counted and links stopped being rewritten until some later verify happened to succeed, and track_status was left saying PENDING for a record that was published all along. The dashboard re-verifies on a timer, so this needed no action from you to happen.

A lookup that does not complete now changes nothing: track_status keeps its last real value and the mail server is left alone. POST /v1/domains/:id/verify still reports results.sending: "failed" for that run, which is what it always meant: the check could not be completed. A CNAME you actually delete still answers (NXDOMAIN), so it still turns tracking off, as before.

Fixed

removing a sending capability now deregisters the domain from the mail server

PATCH /v1/domains/:id with {"capabilities": {"sending": null}} reset our side but left the domain registered on the mail server, still carrying its tracking configuration. Nothing could be sent from it (the send gate has always required an enabled, VERIFIED sending capability), but "removed" did not mean removed, and DELETE afterwards then skipped its own cleanup, because that cleanup keyed on a status the teardown had already reset. A domain re-added under the same name afterwards could end up signed with the abandoned key while you published the new selector, so DKIM failed at every receiver on a domain reported VERIFIED.

Teardown and DELETE are now the same operation on the mail server, and both address the registration we recorded rather than matching on the domain name. Your DKIM keypair is unchanged: it is ours, kept through a teardown on purpose, so re-enabling and re-verifying restores the same selector and the record you already published keeps working. Links in mail already delivered are unaffected: they resolve from the message, not from the tracking configuration.

2026-09-09

request validation is schema-driven, and the API now publishes its own OpenAPI document#

Every documented route (the whole /v1 surface plus the five /api/tenants/* account routes) now validates its request against a schema before the handler runs, the same schema the TypeScript SDK's own types are checked against at compile time so the two can't drift apart. The practical effect: several inputs that used to be silently tolerated now get a clear 400, and the API generates and serves its own machine-readable reference. Every subsection below is something to check your integration against.

Breaking

an unrecognised or repeated query parameter is now 400 validation_error

Every documented endpoint's querystring is now checked against a strict schema.

  • An unrecognised parameter, a typo (?limitt=100) or anything the endpoint doesn't document, is now 400 validation_error naming it. It used to be silently ignored: ?limitt=100 quietly returned the default page size, and nothing told you the parameter you sent was never read.
  • A parameter repeated in the URL (?page=1&page=2) is now also a 400, on every list route. It used to silently take the first value: a repeated query key arrives from the URL as an array, and the schema rejects anything that isn't the single scalar it expects.
  • GET /v1/sandbox/messages/:id's ?recipient=, previously unvalidated, follows the same rule: send it twice and you get a 400, instead of the value being handed to a database lookup that could throw and get silently downgraded to "no inspection data" for that message.

One deliberate exception: to, from, since and until on GET /v1/emails/received still take the first value when repeated. That tolerance predates this work, is kept on purpose, and applies to those four filter fields only; it was not extended to any other parameter, including page/limit on the same endpoint.

Breaking

event_totals/time_series on GET /v1/analytics are a closed set of keys, and days is validated and capped

The wire response itself is unchanged: event_totals and each time_series row still carry exactly the keys they always did. What changed is the type the TypeScript SDK (now published as zsender-sdk) declares for them: an open index signature ({ [eventType: string]: number | undefined }) became a fixed union. If you compiled against the old type and read an arbitrary string key off analytics.eventTotals, that access may now fail to compile.

event_totals and time_series both carry the same seven keys (sent, delayed, failed, held, bounced, clicked, opened), the full set of event types a message can actually record, bound to the webhook parser by a compile-time check so the two can't drift apart again. The mail server can, in principle, report two more event kinds (a DNS error, or one we don't recognize), but neither has a message to attach to a per-message analytics row, so neither has ever appeared here and neither is declared. time_series rows also carry date and delivered (Postal's own sent event, renamed so it doesn't collide with the attempted-count sent above).

?days= was also read with no validation at all: a non-numeric value (?days=lots) coerced to NaN and was passed straight into a date calculation, producing an unpredictable window rather than an error. It is now validated: a non-numeric value, or one over 365, answers 400 validation_error. 365 is new; the old code had no upper bound. A repeated ?days=7&days=30 is also now a 400, not silently accepted: it already produced the same NaN breakage as ?days=lots (a repeated query parameter arrives as an array, and Number(['7','30']) is NaN), so there was no working behaviour on that input to preserve.

Breaking

since/until on GET /v1/emails/received must be one of two forms

These two filters used to be handed straight to new Date(...), which accepts far more than a documented API parameter should: ?since=March 1 2026 worked. They now accept exactly two forms:

  • a date-only YYYY-MM-DD, interpreted as midnight UTC. This already produced midnight UTC before this change, so a caller already sending bare dates sees no behaviour difference, only a value that is now actually validated instead of accepted by accident;
  • a full RFC 3339 timestamp with an explicit offset (2026-01-01T00:00:00Z, 2026-01-01T00:00:00+02:00).

Anything else (a timestamp missing its offset like 2026-01-01T00:00:00, an invalid calendar date like 2026-02-30, or free text like March 1 2026) is now 400 validation_error.

Breaking

the shape of a validation error's details array changed

Most documented routes changed how they validate a request (Fastify's own schema-validation pipeline now runs before the handler, instead of each route validating by hand inside a try/catch), and that changes the shape details carries on a 400 validation_error.

For every documented route with a schema attached (all of /v1, plus the five /api/tenants/* account routes, except the two call-outs below), details is now the array Fastify's own validator produces:

diff
- [{ "code": "invalid_type", "path": ["name"], "message": "Required" }]
+ [{ "keyword": "invalid_type", "instancePath": "/name", "schemaPath": "#/name/invalid_type", "message": "Invalid input: expected string, received undefined", "params": { "expected": "string" } }]

code/path became keyword/instancePath/schemaPath; validation-specific data that used to sit at the top level of each issue (e.g. expected) now lives under params. If your integration reads details[].code or details[].path, change it to details[].keyword / details[].instancePath.

Two routes still return a raw issue array instead of the shape above: POST /v1/sandbox/messages/:id/simulate (its body is deliberately validated by hand, not by a schema) and PUT /api/settings (not part of the documented /v1//api/tenants/* surface). For both, details kept its old field names, but the values changed anyway, because this same work also upgraded the validation library itself (zod 3 to 4):

diff
- [{ "code": "invalid_type", "expected": "string", "received": "undefined", "path": ["name"], "message": "Required" }]
+ [{ "expected": "string", "code": "invalid_type", "path": ["name"], "message": "Invalid input: expected string, received undefined" }]

received is gone as its own field (folded into message); message itself now reads differently for the same failure. A field-format failure (an invalid email, say) is a sharper example of the same rename: code: "invalid_string" + validation: "email" became code: "invalid_format" + format: "email" plus a new pattern field carrying the actual regex.

Behaviour

a request that's both unauthenticated and invalid now answers 400, not 401/403

Fastify validates a request against its schema before any authentication check runs. On every route this work attached a schema to, a request that is BOTH missing/invalid credentials AND schema-invalid (a bad query parameter, a malformed body) now gets 400 validation_error instead of 401 unauthorized / 403 forbidden: validation wins the race, and the auth check never runs. A well-formed request that is merely unauthenticated is unaffected; this only changes the answer when both things are wrong on the same request.

Fixed

PUT /api/tenants/:id no longer leaks internal Postal/Polar ids

The response used to build itself by taking the raw account record and subtracting one secret field, so every rename also leaked postalOrgId, postalServerId, polarCustomerId and polarSubscriptionId. GET /api/tenants/:id was fixed the same way in July 2026; PUT was never migrated. It now re-reads through the same allow list GET already uses, so those four fields are gone from a rename response, and, because it's the same allow list, the response now also carries apiKeys[] and usage[], which it did not before.

Added

GET /openapi.json

The OpenAPI 3.1 document for the whole documented surface (every /v1 route plus the five /api/tenants/* account routes), served unauthenticated. It is generated straight from the same schemas that validate real requests, and our build fails if the published copy goes stale relative to the routes, so it can't drift from what the API actually accepts the way a hand-written reference can.

2026-09-08

webhook payloads are snake_case#

Breaking

the delivery envelope's messageId is now message_id

The REST wire (/v1 and /api) has been snake_case since the 2026-09-06 /v1 normalization, but customer webhook deliveries were never covered by that pass: the webhook payload builder kept emitting the envelope's own field as messageId, so the product spoke two vocabularies depending on whether you were reading a response or receiving an event. This closes that gap.

diff
 {
   "event": "email.delivered",
-  "messageId": "<abc123@rp.zsender.io>",
+  "message_id": "<abc123@rp.zsender.io>",
   "uuid": "evt_9f8c2b7e",
   "timestamp": 1757300000,
   "data": { "...": "unchanged, see below" }
 }

Every data object was already snake_case at every event that ships one (Postal's own payload for the seven mail-lifecycle events, and zSender's own fields for email.received); this change touches only the envelope's messageId key, nothing inside data.

Signature verification is unchanged. The Standard Webhooks signature covers the raw request body bytes, not any particular field inside it: a receiver that verifies the signature and then reads payload.messageId will keep verifying successfully and start reading undefined. The fix is in your handler's field name, not in your signing/verification code:

diff
- const id = payload.messageId;
+ const id = payload.message_id;
Breaking

the "Send test" payload is now the real envelope shape, not a hand-built stand-in

POST /api/webhooks/endpoints/:id/test used to serialize a fixed object literal that had silently drifted from the shape a real delivery actually carries: no uuid, and a top-level test: true instead of the envelope's own mode: "test". It is now built by the same code every real delivery and the sandbox simulator use, so it carries a real, fresh-per-call uuid and mode: "test", and no longer carries test: true at all.

diff
 {
   "event": "email.delivered",
   "message_id": "test-message",
-  "timestamp": 1700000000,
-  "data": { "note": "This is a zSender test webhook event." },
-  "test": true
+  "uuid": "3f9c2e10-...-a-fresh-uuid-per-call",
+  "timestamp": 1700000000,
+  "data": { "note": "This is a zSender test webhook event." },
+  "mode": "test"
 }

If your receiver branches on payload.test === true to discard test deliveries before processing, that branch now silently falls through and a test send is handled as if it were real. Check payload.mode === "test" instead, the same field a sandbox-simulated event already carries. See "Send test" in the webhooks guide.

2026-09-08

an official TypeScript SDK#

Added

the TypeScript SDK (published as zsender-sdk)

A hand-written, zero-dependency TypeScript SDK now exists, covering all 24 /v1 endpoints plus Standard Webhooks signature verification. Not breaking: a new artifact, not a change to the wire, which stays exactly as documented at zsender.io/docs. (As of 2026-09-13 it is published to npm as zsender-sdk@0.1.0, see that entry; on this date it was still a repo-only artifact under the working name zsender, installed from a repository checkout.)

The SDK's public surface is camelCase; it translates to and from the wire's snake_case at its own boundary, so every request/response field name at zsender.io/docs has a direct camelCase counterpart in the SDK (in_reply_to and inReplyTo, open_tracking and openTracking, and so on). One deliberate exception: the event payload handed back by webhooks.verify() keeps the wire's own snake_case, because that payload is the customer-facing webhook delivery contract described in the webhook payload entry, not a /v1 response.

Runs on Node 20+, Deno, Bun and Cloudflare Workers, using web standards only (fetch, WebCrypto, AbortSignal.timeout), nothing else. Retries are on by default, chosen per endpoint on whether repeating the request is actually safe (rate-limit/bad_gateway/service_unavailable statuses only, honouring Retry-After up to a 60s ceiling), and emails.send() carries an automatic per-call idempotency key so a retry can never duplicate a message.

See the SDK guide for install/construct/send/verify, alongside the raw wire examples it sits next to.

2026-09-08

a domain is one resource with two capabilities#

The two domain APIs are now one. Twelve endpoints became six, the sending and receiving domain records became one, and receiving stopped being a separate object with its own ids and its own field names. This is breaking in every direction and there is no compatibility shim: the retired paths answer 404, not a redirect. Every table below is something to grep your own integration for.

Breaking

twelve endpoints became six

NewReplaces
GET /v1/domainsGET /v1/domains + GET /v1/domains/inbound
GET /v1/domains/:idGET /v1/domains/:id + GET /v1/domains/inbound/:id
POST /v1/domainsPOST /api/domains + POST /v1/domains/inbound
PATCH /v1/domains/:idPATCH /v1/domains/:id + PATCH /v1/domains/inbound/:id
DELETE /v1/domains/:idDELETE /api/domains/:id + DELETE /v1/domains/inbound/:id
POST /v1/domains/:id/verifyPOST /api/domains/:id/verify + POST /v1/domains/inbound/:id/verify

GET /api/domains/:id/registrar is unchanged and stays on /api: it is a registrar deep-link hint for the dashboard, not part of the mail API.

One verify call now covers both capabilities. A send+receive domain used to demand two verify requests whose outcomes the caller had to reconcile; it is one request, and the response reports each capability separately (see results, below).

Breaking

one resource, and its field names

json
{
  "id": "clx...",
  "name": "example.com",
  "capabilities": {
    "sending":   { "enabled": true, "status": "VERIFIED", "verified_at": "...", "spf_status": "OK", "spf_found_value": null, "dkim_status": "OK", "dkim_found_value": null, "return_path_status": "OK", "return_path_found_value": null, "dmarc_status": "OK", "dmarc_found_value": "v=DMARC1; p=quarantine", "track_status": "MISSING", "track_found_value": null, "open_tracking": false, "click_tracking": false, "dkim_selector": "a1b2c3", "dkim_public_key": "MIGf..." },
    "receiving": { "enabled": true, "status": "PENDING", "verified_at": null, "verification_token": "...", "txt_status": "MISSING", "txt_found_value": null, "mx_status": "OK", "mx_found_value": "mx.zsender.io (10)" }
  },
  "dns_records": [ { "capability": "sending", "type": "TXT", "name": "example.com", "value": "v=spf1 ..." } ],
  "created_at": "...",
  "updated_at": "..."
}
WasIs now
domain (on the inbound resource)name, the one spelling, on both capabilities
verification_status (inbound)capabilities.receiving.status
status (sending, top level)capabilities.sending.status
verified_at (top level)capabilities.<capability>.verified_at
enabled (inbound, top level)capabilities.receiving.enabled
verification_token, txt_found_value, mx_status, mx_found_value (inbound, top level)inside capabilities.receiving
spf_status, dkim_status, return_path_status, dmarc_status, track_status, open_tracking, click_tracking, dkim_selector, dkim_public_key and their *_found_value siblings (sending, top level)inside capabilities.sending
  • There is no domain-level status any more, and there will not be one. Two independent verification states cannot be honestly collapsed into one value, so there is no combined status with partial states. Read capabilities.sending.status or capabilities.receiving.status. Code that reads domain.status gets undefined, silently, which is the one rename here that fails quietly rather than loudly.
  • capabilities.X === null and enabled: false are different states. null means the capability was never registered (or was torn down back to that pristine state). enabled: false means it is paused: registered, not operating, and keeping its verification status, its found values, its DKIM key and (for receiving) its verification_token. Testing enabled when you mean "is this set up at all" now gives the wrong answer for a paused capability. One exception, because presence is derived rather than stored: sending is present when it is enabled or its status has moved off PENDING. Pausing a sending capability that has never verified therefore serializes as "sending": null rather than {"enabled": false, ...}. Receiving is unaffected: its retained verification_token keeps it visible.
  • txt_status is new. The ownership TXT's own DNS status was computed and thrown away before; it is on the wire now, alongside the txt_found_value that already was. It is not derivable from status, which is sticky: a VERIFIED domain whose TXT vanishes stays VERIFIED.
Breaking

GET /v1/domains no longer returns dns_records

The list endpoint returns every field of the resource except dns_records. Those are publishing instructions, they are large, and an account can hold hundreds of domains. They are on GET /v1/domains/:id, and on the POST, PATCH and verify responses, so nothing that changes a domain makes you fetch them separately. If you were reading records off the list, switch to the detail route.

dns_records is also one flat list across both capabilities now, each entry carrying a capability field ("sending" | "receiving"), because the customer publishes them all in a single visit to their registrar. priority appears only on entries that have one (the MX).

Breaking

creating, updating, deleting and verifying a domain need an admin key

Every write route accepts an API key or a dashboard session and requires the admin permission. A key holding the default send/read permissions gets 403 forbidden. Reads need read.

  • POST /api/domains, POST /api/domains/:id/verify and DELETE /api/domains/:id were JWT-only: an API key could not reach them at all. They are now reachable with an admin key, which is a capability an integration did not previously have.
  • PATCH /v1/domains/:id, the tracking route, had no permission check at all. Any key could flip open_tracking/click_tracking. It now requires admin.
Breaking

POST /v1/domains/:id/verify requires admin, where inbound verify required nothing

Called out separately because it is not a path move. The endpoint it replaces on the receiving side, POST /v1/domains/inbound/:id/verify, accepted any API key or session, applied a rate limit, and ran no permission check whatsoever, so a key holding only the default ['send','read'] could drive the entire inbound verification lifecycle. That key now gets a 403.

The reasoning: verification is not a read. It runs four to five DNS lookups and, on a conclusive check, writes to the mail server to (re-)register the domain and re-evaluate tracking. That is account administration, so a send-only key must not drive it. If your integration verifies domains, issue it an admin key.

Breaking

the request bodies

jsonc
POST  { "name": "example.com", "capabilities": { "sending": { "enabled": true }, "receiving": { "enabled": true } } }
PATCH { "capabilities": { "sending": { "open_tracking": true } } }
PATCH { "capabilities": { "sending": null } }    // tear the capability down

Both are strict: an unrecognised field is a 400 validation_error naming it, never a silent drop.

  • The tracking flags moved. PATCH /v1/domains/:id -d '{"open_tracking": true}', the body documented since 2026-08-25, is now a 400 naming the unrecognised key. They live under capabilities.sending.
  • enabled is required inside a capability object on POST, and optional on PATCH. On create, {"sending": {}} is a 400 naming enabled: there is nothing else a capability object could be saying there. On PATCH a capability object carries whichever fields you are changing, so {"sending": {"open_tracking": true}} is valid and complete; demanding a capability toggle from a caller flipping a tracking flag would be absurd.
  • An EMPTY capability object is a 400 on PATCH too: {"capabilities": {"sending": {}}} names no field to change, exactly like {"capabilities": {}} one level up, and gets the same answer rather than the silent 200 no-op it used to be. Send a field, or null to remove the capability.
  • {"name": "example.com"} with no capabilities still creates a sending-only domain, which is what "add a domain" has always meant. {"capabilities": {}}, though, is a 400: naming the object and then naming neither capability expresses no intent.
  • {"capabilities": {}} on PATCH is a 400 for the same reason, and so is a PATCH naming neither capability.
  • open_tracking/click_tracking under receiving are a 400 naming the field. The two capability objects share a shape, not a meaning.
  • Tracking flags are rejected at create time. Nothing would apply the preference during creation, so accepting it would be a silent no-op. PATCH after creating.
  • name is immutable. The PATCH body has no name field, so sending one is rejected by the same strictness as any unknown key. Renaming means delete-and-recreate; the name is what every DNS record, DKIM key and mail-server route is keyed to.
  • DELETE /v1/domains/:id answers 204 with an empty body, where DELETE /api/domains/:id answered {success, message}.
  • There is still no destination field on a domain. webhookUrl was removed from the inbound resource in July 2026 and has no successor here: where received mail goes is a webhook endpoint subscribed to email.received.
Breaking

a PATCH against a capability the domain does not have is now a 400

PATCH /v1/domains/:id with {"capabilities": {"sending": {"open_tracking": true}}} on a domain with no sending capability used to answer 200 with "sending": null: it stored the flag on a capability the same response said did not exist, and pushed a tracking instruction to the mail server for it. It is now a 400 validation_error naming the capability. Same for {"enabled": false} (pausing something that was never registered), and same class as {"sending": {}} above.

Two things are deliberately still accepted:

  • {"enabled": true}: that is how a capability is ADDED (a receive-only domain gains sending this way, minting its DKIM material), so it is valid on an absent capability by definition.
  • null: tearing down a capability that is already absent is a reset to the state the domain is already in. It is idempotent, changes nothing, and stays a 200.

If your integration flips tracking on a domain it did not create, add the capability first, or read capabilities.sending before patching it.

Behaviour

deleting a domain deletes the mail received for it

DELETE /v1/domains/:id now also deletes that domain's stored received messages (the ones GET /v1/emails/received returns, and their attachments), because sending and receiving are one record now and the message store references it. Before the merge, deleting the sending domain could not touch received mail: it lived on a separate inbound-domain record.

PATCH {"capabilities": {"receiving": null}} does not do this. The record survives, so the stored mail survives with it. Use the teardown when you want to stop receiving; use DELETE only when you mean the archive too. The 30-day retention window is unchanged.

Fixed

deleting a domain no longer tears down another account's mail-server state

DELETE /v1/domains/:id deregistered the domain from the mail server by name, unconditionally. Two accounts can hold the same name, so deleting an unverified example.com could remove the mail-server registration (including the per-domain DKIM key) and the tracking configuration belonging to a different account that had verified the same name. Removing tracking is the worse half: links already sent stop resolving.

The mail-server teardown now runs only when the domain being deleted actually reached VERIFIED for sending, which is the only way it ever had a registration of its own. Nothing else changes: the domain record is deleted either way, and the response is still 204. The receiving side already had this guard.

Superseded on 2026-09-11: the status guard described here was replaced. Teardown and DELETE now address the mail-server registration recorded for this domain instead of checking its status. See that entry ("removing a sending capability now deregisters the domain from the mail server").

Breaking

results on verify means something different

POST /v1/domains/:id/verify answers the serialized domain plus "results": { "sending": "ok" | "failed" | "skipped", "receiving": ... }. The verdict is never in results; it is capabilities.<capability>.status, and results says only whether the check ran. Two of the three values changed meaning:

  • A capability whose 15-second per-domain cooldown was already held now reports 'ok', not 'skipped'. This is not a rare path: the dashboard polls every 20 seconds per tab, so a second open tab lands here routinely. 'ok' is true under its own definition: the cooldown only ever refuses a record that was refreshed seconds ago, so the outcome genuinely is on the resource. A client that rendered 'skipped' as "this capability isn't turned on" was stating the opposite of the truth about a live capability.
  • A receiving capability that is enabled but has no verification_token now reports 'failed', not 'skipped'. The ownership check cannot run at all without a token, so the check did not complete, which is exactly what 'failed' means. 'skipped' would have told a customer whose MX is live that receiving is switched off.

After both changes 'skipped' means exactly one thing: the capability is not enabled on this domain. 'ok' means the check ran (and does not mean VERIFIED: a domain that has published nothing is a successful check with a PENDING verdict). 'failed' means the check could not be completed: a DNS lookup threw, the mail server could not be provisioned, or the token case above.

2026-09-06

DMARC is reported, and a sending domain stops advertising an MX#

Breaking

a sending domain's dns_records[] no longer contains an MX entry

GET /v1/domains and GET /v1/domains/:id previously included {"type":"MX","name":"{domain}","value":"mx.zsender.io","priority":10} in every sending domain's record list, whether or not the account had an inbound domain. It was never verified there: the sending domain has no mx_status.

The record is a constant, so you do not need an endpoint to reproduce it: an MX at the apex (name = the domain itself) with value mx.zsender.io and priority 10. Publish it only if you actually want to receive mail on that domain.

Superseded on 2026-09-07 by the domain-surface unification (see the domain entry of 2026-09-08): the /v1/domains/inbound* endpoints named in the next paragraph no longer exist, and the receiving MX now appears in the single dns_records list on the unified resource, tagged "capability": "receiving". The rest of this entry (that a sending capability publishes no MX, and that it never had an mx_status) still holds.

Which endpoint returns it: only POST /v1/domains/inbound (registering the domain for receiving) attaches a dns_records array. GET /v1/domains/inbound/:id, the list, verify and PATCH all return the inbound resource without dns_records, and re-POSTing a domain you already registered answers 409. So for an inbound domain that already exists there is no endpoint that hands the record back; build it from the constant above. What the inbound resource does give you, and the sending domain never did, is a real mx_status telling you whether that MX is actually published.

Added

dmarc_status and dmarc_found_value on a sending domain

DMARC is now checked on every verify. It is a presence check, not a match: any valid policy is OK, MISSING means no v=DMARC1 record was found, and INVALID means more than one exists (RFC 7489 has receivers apply no DMARC processing at all in that case). The fourth value is PENDING, "we have not reached a conclusion yet", and it is not an edge case: it is the default, so it is what every domain that existed before this release reports until its next verify, and it is also what a resolver error leaves behind. Branch on it explicitly; do not let it fall into an else that means "no DMARC published". dmarc_found_value carries the published policy whenever one was read, including when the status is OK, unlike the other *_found_value fields, which are populated only for INVALID. DMARC never affects status: a domain with no DMARC record still verifies and still sends.

Both fields moved under capabilities.sending on 2026-09-07 (see the domain entry of 2026-09-08).

2026-09-06

the /v1 surface is normalized: one error shape, one list shape, snake_case#

This change normalized the shape of the whole /v1 API (and the error bodies of the /api dashboard surface alongside it) so that one SDK, one error-handling branch, and one pagination helper can cover the entire product instead of each endpoint inventing its own. This is the single largest breaking change to the wire format since launch. Every table below is something to grep your own integration for.

Breaking

the error contract

Every error on the whole API (/v1 and /api alike) now answers the same shape:

json
{ "error": "<slug>", "message": "human-readable text", "details": "present on some errors" }

error is a stable, machine-readable slug: branch on it, not on message, which may be reworded at any time. There are exactly eleven slugs:

HTTPerrorMeaning
400validation_errorRequest body/params/query failed validation
401unauthorizedMissing or invalid credential
403forbiddenAuthenticated, but not allowed to do this
404not_foundResource doesn't exist, or belongs to another account
409conflictDuplicate resource, or a request already in flight
429quota_exceededMonthly send quota reached, not retryable this month
429rate_limitedPer-minute rate limit hit, retryable in seconds
502bad_gatewayThe mail server errored or was unreachable
503sandbox_unavailableTest-mode send routing isn't safely usable right now
503service_unavailableA non-sandbox upstream dependency is down
500internal_errorUnclassified server-side failure

quota_exceeded and rate_limited share a status but never a slug (the correct client response differs); the same is true of sandbox_unavailable and service_unavailable.

Many previously-distinct, hand-written names collapsed onto these eleven slugs. If your integration branches on any of the following, it will stop matching. Check error against the table above instead; every one of these is gone:

Old name (gone)New slug
ValidationErrorvalidation_error
Unauthorizedunauthorized
Forbidden / TestModeKeyforbidden
NotFoundnot_found
RateLimitExceededrate_limited
InternalErrorinternal_error
CaptchaRequiredvalidation_error
AlreadySubscribedconflict
PolarUnavailableservice_unavailable
ActiveSubscriptionconflict

This list is not exhaustive: every route on the API used to hand-build its own {error, message} object ({error: 'Not Found'} on one route, {error: 'NotFound'} on another), and all of them now go through the same eleven-slug serializer. If your integration matched on a string that isn't in either table above, it was one of these ad hoc names and it is also gone.

One upstream status collapse worth calling out on its own: on POST /v1/emails/send, a 422 from the mail server (e.g. an attachment with an empty-string name) now surfaces as 400 validation_error, and a 502 or 504 from it both surface as 502 bad_gateway. If your integration distinguished "the mail server rejected this outright" from "the mail server was slow", it can no longer do so from the HTTP status or slug; only message still carries the distinction.

Breaking

every list answers { data, pagination }

Every /v1 list endpoint now takes page + limit and answers:

json
{ "data": [ /* ... */ ], "pagination": { "page": 1, "limit": 20, "total": 150, "pages": 8 } }
EndpointOld shapeNew shape
GET /v1/emails{ emails: [...], pagination }{ data: [...], pagination }
GET /v1/suppressions{ items: [...], total }, params take/skip{ data: [...], pagination }, params page/limit
GET /v1/emails/received{ messages: [...], pagination }{ data: [...], pagination }
GET /v1/domains/inbound{ domains: [...] } (no pagination at all){ data: [...], pagination }
GET /v1/sandbox/messagesparams skip/takeparams page/limit, { data: [...], pagination }
GET /v1/domainswas GET /api/domains, answering { success: true, domains: [...] } (camelCase)GET /v1/domains, answering { data: [...], pagination }
Breaking

single-resource wrappers removed

EndpointOld shapeNew shape
GET /v1/emails/:id{ message: {...} }{...} (unwrapped)
GET /v1/usage/current{ usage: {...} }{...} (unwrapped)
GET /v1/analytics{ analytics: {...} }{...} (unwrapped)
Breaking

send request fields renamed (strict: the old name is now a 400 naming it)

The send schema is strict: an old field name is not silently ignored, it is a 400 that names the exact field, on every affected request:

Old fieldNew field
replyToreply_to
inReplyToin_reply_to
attachments[].contentTypeattachments[].content_type
Breaking

send response reshaped

Old fieldNew field
success (boolean)removed: a 200 status is the success signal
messageIdmessage_id
messages, suppressedunchanged
Breaking

tracking fields on PATCH /v1/domains/:id

This is a second rename of the same fields introduced on 2026-08-25 (see that entry); if you integrated against the original camelCase names, update again:

Old fieldNew field
openTrackingopen_tracking
clickTrackingclick_tracking

Superseded on 2026-09-07: the names are unchanged, but they are no longer top-level: the body is now {"capabilities": {"sending": {"open_tracking": true}}}, and the route requires an admin key. See the domain-surface entry.

Breaking

sending-domain reads moved off /api

Old routeNew route
GET /api/domainsGET /v1/domains
GET /api/domains/:idGET /v1/domains/:id

Both still accept either an API key or a dashboard session, same as before. The three routes that change a sending domain (POST /api/domains, POST /api/domains/:id/verify, DELETE /api/domains/:id) are unaffected and remain JWT-only on /api.

Superseded on 2026-09-07: those three write routes moved to /v1/domains too and now take an admin API key as well as a session. GET /api/domains/:id/registrar is the only domain route left on /api. See the domain-surface entry.

Breaking

every /v1 field renamed from camelCase to snake_case

Mechanical for every field not already listed above (createdAt to created_at, verifiedAt to verified_at, spfStatus to spf_status, dkimStatus to dkim_status, returnPathStatus to return_path_status, spfFoundValue/dkimFoundValue/returnPathFoundValue to their _found_value equivalents, trackStatus to track_status, eventType to event_type, smtpOutput to smtp_output, sentWithSsl to sent_with_ssl, durationMs to duration_ms, spamStatus to spam_status, rawMessage to raw_message, verificationStatus to verification_status, verificationToken to verification_token, txtFoundValue to txt_found_value, mxStatus/mxFoundValue to mx_status/mx_found_value). The ones that are not a plain rename are called out individually below.

Behaviour

changes that are not renames and can silently break working code

  • GET /v1/suppressions/:email now answers 200 for an address that isn't suppressed, body {"suppressed": false}. It used to be a 404. If your integration treated the HTTP status as the signal (catching the 404 as "not suppressed"), it will now see every address as suppressed: read the suppressed field, not the status.
  • last_event on GET /v1/emails is null, not "sent", for a message with no events yet. The old default asserted a delivery that had not happened. The field itself is also renamed from two fields to one: latestEvent/latestEventTime became last_event (a single string or null; the timestamp is gone, so pull the timestamp from GET /v1/emails/:id/events if you need it). GET /v1/emails/:id carries the SAME field, same name, same null rule: it used to be a separately-named status field with the opposite default ('sent' rather than null), so the identical message read last_event: null from the list and status: "sent" from the detail route. There is no status field on GET /v1/emails/:id any more; read last_event.
  • POST /v1/domains/inbound's response no longer includes a message field, and the DNS instructions moved from a records: { ownership, routing } object to a flat dns_records: [...] array (same per-record shape GET /v1/domains already used: {type, name, value, priority?}, type disambiguates TXT from MX). POST /v1/domains/inbound/:id/verify's response no longer includes the transient txtStatus field: verification_status on the same response already reflects the outcome of the check that ran.
  • GET /v1/emails rows no longer include metadata. It was the upstream mail server's full internal webhook payload, not something built for the wire. The same field is dropped from GET /v1/emails/:id/events' events and from suppression rows, for the same reason.

Not touched by this work: the email.received webhook envelope (its own messageId field stays camelCase, see the event list), and every /api dashboard route's success response body (only its error bodies moved onto the shared shape above; e.g. GET /api/webhooks/endpoints, GET /api/tenants/:id, and GET /api/billing/subscription keep their existing camelCase fields).

Superseded on 2026-09-08: the webhook envelope's messageId was renamed to message_id. See that entry.

2026-09-05

inbound mail is now stored, and its webhook payload shrank#

Breaking

email.received carries metadata only, and two fields changed

email.received no longer carries text, html, or attachment bytes. The webhook data object for this event is now metadata only. If your integration reads data.html, data.text, or data.attachments[].content off the webhook payload, it will start seeing undefined where it used to see content: those keys are gone, not empty. Fetch the body and attachments instead from the new GET /v1/emails/received/:id (and GET /v1/emails/received/:id/attachments/:attachmentId for raw bytes), using the webhook payload's id field. See Reading stored messages and the email.received payload.

We split it this way so an inbound webhook stays small enough for a serverless runtime's request-body limit; this payload could previously carry up to 20 MB of base64 for a message with attachments.

The timestamp field is renamed: created_at is now received_at. Same value, new name, on both the webhook payload and the new GET /v1/emails/received routes: the two email.received surfaces now agree on shape. It's also no longer nullable: a missing/unparseable timestamp from the mail server now falls back to the time zSender received the message, rather than storing nothing, so received_at is always a real ISO string.

email_id (a number) is gone; use id (a string) instead. The field held Postal's own internal message id. id is zSender's own storage id for the message, the value to pass to GET /v1/emails/received/:id.

Added

stored inbound mail and the routes to read it

  • Reading a sending domain now takes an API key, not only a dashboard session, closing the one step of the tracking flow an integration couldn't previously do with the credential it already had (PATCH /v1/domains/:id takes an API key, but nothing that handed back the DOMAIN_ID it needs did). By the time this reached production the read routes had also moved from /api/domains(/:id) to /v1/domains(/:id) as part of the surface-normalization work (see the 2026-09-06 "sending-domain reads moved off /api" table for the final shape); this bullet is kept only as the historical reason the move happened.
  • message_id, in_reply_to, references on the email.received payload: previously only message_id was present, and it was not guaranteed to be in the angle-bracket form the send API's in_reply_to/references require. All three are now normalized into <id@host> form on the way in when the source header is present, so a non-empty message_id can be passed straight to in_reply_to with no string manipulation. A message with no Message-ID header of its own still emits message_id: ""; check for that before using it as in_reply_to, which rejects an empty string the same as any other malformed id. See Threading a reply.
  • GET /v1/emails/received, GET /v1/emails/received/:id, GET /v1/emails/received/:id/attachments/:attachmentId: every message that reaches a VERIFIED, enabled receiving domain is now stored, independent of whether any webhook endpoint subscribes to email.received. Retention is 30 days, the same rule webhook deliveries already follow. A test-mode key (zst_...) is refused 403 forbidden on all four routes.
  • GET /v1/emails/received/:id/raw: the message source as message/rfc822, for full headers or for resolving an inline <img src="cid:..."> by parsing the MIME yourself (attachment metadata carries no content_id). Two caveats, both deliberate. It is the mail server's stored copy, not the bytes the sender transmitted: four headers are prepended on receipt (X-Postal-Spam, X-Postal-Spam-Threshold, X-Postal-Spam-Score, X-Postal-Threat), with everything from Received: onward unmodified. And it is fetched on demand from the mail server, so it answers 409 Conflict once that server has pruned the message (which can happen before the 30-day window above) and 502 Bad Gateway if the server is unreachable. The two are never interchangeable: a 409 says "stop retrying", so an outage does not produce one.
  • Inbound mail for a suspended account is now dropped without being stored. Previously the receiver never checked suspension at all. No customer-visible API change; noted because a suspended account's receiving domain stops producing anything to fetch.
  • Mail with no subscribed webhook endpoint is stored, not discarded. Before this change, a message arriving at a verified domain with nothing subscribed to email.received was accepted and then genuinely gone, with no way to retrieve it after the fact. It is now stored exactly like any other received message; only the webhook notification is skipped.

Not built

Attachment metadata carries no content_id, so there is no supported way to map an inline <img src="cid:..."> reference in a received message's HTML directly to the attachment it points at from the JSON endpoints alone; you'd have to parse the MIME yourself from GET /v1/emails/received/:id/raw (added after this note was first written; see the Added section above) to recover it. Contact support if this blocks you.

2026-08-25

per-domain open & click tracking control#

Added

PATCH /v1/domains/:id turns tracking on or off per domain

  • PATCH /v1/domains/:id: { openTracking?, clickTracking? }, at least one required (an empty body is a 400). Authenticates with an API key or a JWT. Turns open/click tracking on or off for that domain and returns the domain object.
  • openTracking, clickTracking, trackStatus on every domain response. Both flags default to false: tracking stays off on a domain until you turn it on.

The flags are intent, not a DNS override: they only take effect while the domain's track.{domain} CNAME resolves (trackStatus === "OK"). Publishing the CNAME alone no longer turns tracking on by itself; see Open and click tracking.

The request can also fail after the flags are already saved: if the mail server couldn't be reached, or hasn't registered the domain yet, the response is a 502, not a 200:

json
{
  "error": "Bad Gateway",
  "message": "Tracking preference saved, but could not be applied to the mail server. Re-verifying the domain will retry it."
}

Your setting is stored either way; re-verifying the domain retries the apply.

Previously the only way to turn tracking off was deleting the track CNAME and re-verifying the domain, which also ships live mail with rewritten links pointing at a host that no longer resolves, for up to an hour (see "Every message from a domain" in the transactional entry, now superseded). Leave the CNAME published and flip the flag instead: tracking turns off immediately, and links already sent keep working.

2026-08-25

transactional does not disable tracking#

Corrected documentation

transactional: true does not turn off open or click tracking. It never did.

No behaviour changed on this date: the documentation was wrong from the day transactional shipped, and the correction is here because someone acted on the wrong version.

If the sending domain has tracking enabled, a transactional message still carries the tracking pixel and its links are still rewritten through track.yourdomain.com. If you wrote a privacy policy, a DPA, or a security questionnaire answer that says password resets and receipts carry no tracking pixel, that statement is currently false and needs revisiting.

transactional: true has exactly two effects, both unchanged and both working:

  1. no unsubscribe offer (List-Unsubscribe / List-Unsubscribe-Post are not attached), and
  2. UNSUBSCRIBE and MANUAL suppressions are overridden (HARD_BOUNCE and COMPLAINT never are).

The same correction applies to X-Zsender-Transactional: true on the SMTP relay.

Why. We used to mark the message with X-AMP: skip, the underlying mail server's own documented per-message opt-out, and that header is inert in every released version of it. We now know the mechanism (it reads the header, then compares it against the wrong type, so the match never succeeds) and have confirmed it on delivered mail.

X-AMP is no longer sent, and no longer reserved. A header on delivered mail asserting the opposite of what happened is worse than no header, so it is gone rather than kept as a fallback. If you inspect your delivered transactional mail you will no longer find X-AMP: skip on it; nothing about tracking changes, because the header never did anything. You may now set X-AMP yourself through headers; it is passed through untouched and affects nothing.

What does work, and is all that works:

ScopeHow
Every message from a domainSuperseded the same day: see the per-domain tracking entry. Turn openTracking/clickTracking off via PATCH /v1/domains/:id instead; don't turn tracking off by deleting the track CNAME, which ships live mail with rewritten links pointing at a host that no longer resolves.
One linkWrite the URL as https+notrack://example.com. The +notrack is stripped on the way out. Corrected 2026-09-15: as written this was only true while the domain's click_tracking was true; with it off the literal scheme was delivered. Fixed, see the https+notrack:// entry.

Opens and clicks cannot be separated per message, which is why no trackOpens / trackClicks pair is offered.

Documented

The suppression endpoints existed and worked but were missing from the reference. No change to any of them; see Suppressions.

  • GET /v1/suppressions: who currently receives nothing from you, with reason, search, take (1 to 200, default 50) and skip. Returns { items, total }, newest first; total counts your filter, not the page.
  • GET /v1/suppressions/:email: check one address. A 404 is the answer "not suppressed", not an error.
  • POST /v1/suppressions and DELETE /v1/suppressions/:email: were mentioned only in the test-mode restrictions table; they now have request and response shapes.
  • The four reason values (HARD_BOUNCE, COMPLAINT, UNSUBSCRIBE, MANUAL) were previously inferrable only from the transactional description. They are now stated with what writes each.
2026-08-24

custom headers, threading, and transactional mail#

Breaking

bypass is removed, and unrecognised fields are rejected

Both of these fail with a 400, so an affected integration finds out on its next call rather than quietly sending different mail.

bypass has been removed. Use transactional: true instead.

"bypass": true is now an unrecognised field and returns 400. Rename it. transactional: true does everything bypass did (a message so marked is still delivered to recipients suppressed for UNSUBSCRIBE or MANUAL, never HARD_BOUNCE or COMPLAINT, which nothing overrides), and it also stops us attaching an unsubscribe offer to that message.

This entry originally said transactional also turned off open/click tracking. It never did; see 2026-08-25.

That second half is the reason they merged. Without it, a password reset carried List-Unsubscribe, Gmail drew an Unsubscribe button beside your name, and a user who pressed it was suppressed for your whole account, losing every message you send them, including the password reset they were trying to get. Setting bypass treated the symptom on one send; transactional removes the cause.

Unrecognised fields are rejected instead of ignored.

Any field the send endpoint does not understand now returns 400 naming it. Previously such fields were dropped silently and the call still returned 200, which is how headers could sit in our documentation, with an example, for months without ever reaching the message. If you send fields that are not in the request body reference, remove them.

Added

headers, threading fields and transactional

  • headers: custom headers now genuinely work. Names we or the mail server own are refused by name (all of them at once, so one round trip is enough to fix a request); everything else is passed through as written. See Reserved headers.
  • inReplyTo and references: reply inside an existing thread instead of starting a new one. Names and types match Nodemailer's message options, including references as either an array or one space-separated string. Message-IDs must be in angle brackets; a value without them is a 400 rather than a silent repair.
  • transactional: see above.
  • X-Zsender-Transactional: true: the same thing over the SMTP relay. It is a control header addressed to zSender and is removed before the message is relayed, so it never reaches your recipient.

Message-ID remains ours to assign over the HTTP API, and setting it via headers is refused. To chain messages of your own, read messageId off the send response and pass it as the next message's inReplyTo.

Corrected documentation

Neither is a behaviour change: the docs were wrong about behaviour that already existed.

  • GET /v1/emails/{id}/events accepts the id from the message list or the messageId from a send response. It does not accept the X-Postal-MsgID token found in delivered mail; three identifiers circulate and only two work here.
  • The SMTP relay section claimed a Bcc-only message is rejected with 553. It is not: recipients come from the SMTP envelope, never from the To/Cc/Bcc headers, so a Bcc-only message is relayed like any other. 553 is only ever an empty MAIL FROM.