Skip to content
Docs menu

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 conflict rather 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."
  }'

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:

  • 429 means too fast. It's either rate_limited (the per-minute limit above: carries a Retry-After header in seconds, and the same value again as retry_after in the JSON body; wait that long and retry) or quota_exceeded (the monthly cap: no Retry-After is 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.
  • 409 means conflict, and on this route specifically it means an Idempotency-Key you reused is still being processed by another in-flight call. It carries no Retry-After either. 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.