2026-09-22
zsender-sdk@0.2.0: an unusable 2xx is a typed error#
^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.
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.
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 coveredZsenderConnectionError) instead of being rethrown immediately as it was in0.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.
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_blockedis now inAPI_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 reacheserror.codewith its message intact), though not on0.1.0, where an unrecognized slug does neither; but0.2.0is the first version where it's also a value your editor autocompletes.- A doc-comment fix:
SendEmailInput's JSDoc onhttps+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.