Limits and reliability
What bounds a tenant's sending, what a caller sees when it's hit, and how to retry a send without risking a duplicate.
After this page you'll know the two independent ceilings on sending (a monthly quota and a per-minute rate), which of the two responses you get back, 429 or 409, and what each means for whether and when to retry, and how to make a retried POST /v1/emails/send safe with Idempotency-Key.
Monthly quota
Every tenant has a monthly email quota set by plan: FREE is 1,000 emails/month, PRO is 50,000 emails/month. It resets on the first of the calendar month and counts every recipient a LIVE-mode send reaches: a single call to to+cc+bcc with 10 addresses counts as 10 against the quota, not 1.
The quota is reserved before the send happens, not counted after: the server atomically increments the tenant's usage row first, and only then hands the message to the mail server. If that reservation would push usage over the plan limit, the request never reaches the mail server at all: it fails immediately with 429 quota_exceeded. If the reservation succeeds but the send itself then fails (the mail server rejects it, times out, or any other error), the reservation is refunded: a failed send does not permanently consume quota. The refund is best-effort: if it can't be written, the real send error is still what you see, not a refund failure, so an occasional refund miss under a mail server outage errs toward showing usage as slightly higher than it really is, never toward silently blocking sends that should be allowed.
Test-mode sends (a zst_... key) never touch this quota at all: they're bounded by their own fixed rate instead; see the next section and Test mode.
Per-minute rate limit
Separately from the monthly quota, POST /v1/emails/send is rate-limited per minute by plan: FREE is 10/minute, PRO is 500/minute. This is a sliding-per-window count of calls to that one route, not recipients: a call that sends to 10 addresses still counts once against it. The window is tracked separately from the monthly quota, so hitting one doesn't affect the other.
Test-mode sends draw from their own fixed bucket instead of the plan's live rate: 60 per minute, the same regardless of plan: exercising the sandbox heavily can't eat into a FREE tenant's 10/minute live allowance, and a PRO tenant doesn't get a bigger sandbox bucket just for paying more. See Test mode.
The rate-limit headers, and which routes send them
X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (a Unix timestamp, in seconds, for when the current window resets) come from whichever rate limiter is attached to a route: they are not sent on every response, and where they are sent, they ride on every response from that route, not just the ones that get rejected. Don't build back-pressure logic that assumes a route without them is fine, or that a 200 never carries them.
On the documented surface, that's exactly two routes: POST /v1/emails/send (the per-minute send limit above), and POST /v1/domains/:id/verify, which carries a separate, much more generous per-account ceiling on DNS re-checks (300 requests per 5 minutes): deliberately loose, since the thing that actually bounds that route's work is a 15-second per-domain cooldown, not this. Full field-level detail for each route is on /docs/api/emails and /docs/api/domains.
Idempotency
Pass an Idempotency-Key header (any string, up to 255 characters) on POST /v1/emails/send to make a retried call safe. The server tracks each key per tenant, so two different tenants can reuse the same string without colliding:
- First call with a key: proceeds and sends the mail. On success, the response is cached against that key for 24 hours.
- Repeat of the same key, after the first call succeeded: the cached response is replayed verbatim (same status, same body) and nothing is sent twice.
- Repeat of the same key while the first call is still in flight: answered with
409 conflictrather than being queued or replayed: there's no result yet to hand back. - Repeat of the same key after the first call failed: the failed attempt's reservation on the key is released, so a retry with the same key is treated as a fresh attempt and proceeds normally rather than being stuck behind a failure forever.
- A processing key that never resolves (the process crashed mid-send, say) is reclaimed after 60 seconds, so a caller is never wedged behind a lock that will never clear.
The SDK already does the useful part of this for you automatically: every emails.send() call generates and attaches its own key, reused across that call's own internal retries, so the SDK's retry loop can never turn into a duplicate send. You only need to pass one yourself when you are the one retrying: a queue worker redelivering a job, a webhook handler that might run twice for one event. Full detail on that automatic behavior is on the SDK page; this is the same mechanism from the other side, driving the raw header directly:
curl -X POST https://api.zsender.io/v1/emails/send \
-H "Authorization: Bearer $ZSENDER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-4471-reminder" \
-d '{
"to": ["recipient@example.com"],
"from": "hello@yourverifieddomain.com",
"subject": "Invoice 4471 is due",
"text": "A friendly reminder that invoice 4471 is due this week."
}'import { Zsender, ZsenderError, ZsenderConnectionError, ZsenderResponseError } from 'zsender-sdk';
const zsender = new Zsender(process.env.ZSENDER_API_KEY!);
// send() already attaches its own idempotency key to every call and reuses it across
// that call's own retries -- see /docs/sdk. Pass your own key here only when YOU are
// the one retrying: a queue worker re-delivering the same job, a webhook handler that
// might run twice for one event. Reuse the same string across those attempts.
const idempotencyKey = 'invoice-4471-reminder';
try {
const sent = await zsender.emails.send(
{
to: ['recipient@example.com'],
from: 'hello@yourverifieddomain.com',
subject: 'Invoice #4471 is due',
text: 'A friendly reminder that invoice #4471 is due this week.',
},
{ idempotencyKey },
);
console.log(sent.messageId);
} catch (err) {
if (err instanceof ZsenderConnectionError || err instanceof ZsenderResponseError) {
// The case this whole key exists for: the outcome is UNKNOWN. Nothing answered, or
// something answered and it was not usable -- including a 2xx whose body is missing a
// field the endpoint declares, which is detected after the response was read and is
// therefore never retried by the SDK for you. `err.idempotencyKey` is the key that
// actually went out on the wire; re-send with exactly that string and the API either
// replays the completed send or performs one that never happened. Minting a fresh key
// here is what produces a duplicate email.
console.error('unknown outcome, retry with', err.idempotencyKey);
} else if (err instanceof ZsenderError && err.code === 'conflict') {
// Another call with this exact key is still being processed -- not a duplicate
// result to replay, just not resolved yet. There's no Retry-After to read here
// (that's a rate_limited thing); back off a short, fixed amount and try again.
console.error('a send with this idempotency key is already in flight');
} else {
throw err;
}
}Field-level detail for the send call itself is on /docs/api/emails; see also Send an email.
Telling 429 from 409
These are two unrelated conditions that happen to both show up as failures on the same route, and they call for different retry behavior:
429means too fast. It's eitherrate_limited(the per-minute limit above: carries aRetry-Afterheader in seconds, and the same value again asretry_afterin the JSON body; wait that long and retry) orquota_exceeded(the monthly cap: noRetry-Afteris sent, because there's no short wait that fixes a monthly quota; don't retry this one until next month, or until the tenant upgrades). Both share the 429 status but are different slugs for exactly this reason. See the error contract for the full slug list.409means conflict, and on this route specifically it means anIdempotency-Keyyou reused is still being processed by another in-flight call. It carries noRetry-Aftereither. Back off a short, fixed amount and check again rather than treating it like a rate limit; the original call will resolve on its own, at which point retrying with the same key replays its result instead of conflicting again.