Rate limits and quotas
Every request limit the API enforces, the headers that report it, what a 429 looks like, and the monthly and daily sending quotas that sit on top.
How request limits work#
Each rate-limited endpoint keeps a counter per credential, per method and exact path, over a fixed window:
- Per credential. Requests are counted against the API key (or OAuth token) that made them — not your IP address, and not your team. Two keys each get their own allowance.
- Per exact path. The path includes IDs, so
POST /domains/dom_a…/verifyandPOST /domains/dom_b…/verifyare counted separately, and so arePOST /emailsand its aliasPOST /emails/send. - Fixed window. The window starts with the first request and resets completely when it ends; it doesn't slide. Most windows are one minute.
- Free plan runs at half rate. Teams on the Free plan (or with no subscription) get half of each limit, rounded down. Upgrading raises the limit within five minutes. If you need more than your plan gives, contact support — limits can be raised per team.
GET requests (lists and lookups) have no per-endpoint limit, except GET /contacts/export. Everything, reads included, is still subject to the edge limits.
Limits by endpoint#
Paths are relative to https://api.poststack.dev. Anything not listed has no per-endpoint limit.
Sending and content
| Endpoint | Paid plans | Free plan |
|---|---|---|
POST /emails | 60 / minute | 30 / minute |
POST /emails/send | 60 / minute | 30 / minute |
POST /emails/batch | 10 / minute | 5 / minute |
POST /emails/preview | 30 / minute | 15 / minute |
POST /emails/spam-preview | 30 / minute | 15 / minute |
POST /emails/:id/cancel | 30 / minute | 15 / minute |
POST /email-validations | 60 / minute | 30 / minute |
POST /email-validations/batch | 10 / minute | 5 / minute |
POST /broadcasts | 30 / minute | 15 / minute |
PATCH /broadcasts/:id | 30 / minute | 15 / minute |
POST /broadcasts/:id/send | 10 / minute | 5 / minute |
POST /broadcasts/:id/test | 10 / minute | 5 / minute |
POST /broadcasts/:id/resend | 10 / minute | 5 / minute |
POST /broadcasts/:id/cancel | 10 / minute | 5 / minute |
POST /broadcasts/:id/end-ab-test | 10 / minute | 5 / minute |
POST /templates | 30 / minute | 15 / minute |
POST /templates/presets/:presetId/use | 30 / minute | 15 / minute |
PATCH /templates/:id | 30 / minute | 15 / minute |
DELETE /templates/:id | 30 / minute | 15 / minute |
POST /templates/:id/duplicate | 30 / minute | 15 / minute |
POST /templates/:id/render | 60 / minute | 30 / minute |
POST /templates/:id/publish | 30 / minute | 15 / minute |
POST /templates/:id/unpublish | 30 / minute | 15 / minute |
POST /workflows | 30 / minute | 15 / minute |
PUT /workflows/:id/graph | 60 / minute | 30 / minute |
POST /workflows/:id/activate | 30 / minute | 15 / minute |
POST /workflows/:id/pause | 30 / minute | 15 / minute |
POST /workflows/:id/trigger | 10 / minute | 5 / minute |
POST /workflows/events | 300 / minute | 150 / minute |
POST /inbound/:id/reply | 60 / minute | 30 / minute |
POST /inbound/:id/forward | 60 / minute | 30 / minute |
POST /inbound/:id/draft-reply | 60 / minute | 30 / minute |
Account, audience and configuration
| Endpoint | Paid plans | Free plan |
|---|---|---|
POST /domains | 10 / minute | 5 / minute |
POST /domains/:id/verify | 10 / 15 minutes | 5 / 15 minutes |
POST /domains/:id/dkim/rotate | 5 / 1 hour | 2 / 1 hour |
POST /domains/:id/dkim/rotate/activate | 20 / 15 minutes | 10 / 15 minutes |
DELETE /domains/:id/dkim/rotate | 10 / 1 hour | 5 / 1 hour |
POST /domains/:id/ip | 10 / minute | 5 / minute |
DELETE /domains/:id/ip | 10 / minute | 5 / minute |
POST /api-keys | 10 / minute | 5 / minute |
POST /api-keys/:id/rotate | 10 / minute | 5 / minute |
DELETE /api-keys/:id | 10 / minute | 5 / minute |
POST /webhooks | 10 / minute | 5 / minute |
PATCH /webhooks/:id | 10 / minute | 5 / minute |
DELETE /webhooks/:id | 10 / minute | 5 / minute |
POST /webhooks/:id/test | 10 / minute | 5 / minute |
POST /webhooks/:id/rotate-secret | 10 / minute | 5 / minute |
POST /webhooks/:id/deliveries/:did/replay | 10 / minute | 5 / minute |
POST /webhooks/:id/deliveries/batch-replay | 3 / minute | 1 / minute |
POST /notification-channels | 20 / minute | 10 / minute |
PUT /notification-channels/:id | 60 / minute | 30 / minute |
DELETE /notification-channels/:id | 60 / minute | 30 / minute |
POST /notification-channels/:id/test | 10 / minute | 5 / minute |
POST /notification-channels/:id/deliveries/:deliveryId/replay | 10 / minute | 5 / minute |
POST /contacts | 60 / minute | 30 / minute |
POST /contacts/import | 10 / minute | 5 / minute |
GET /contacts/export | 5 / minute | 2 / minute |
POST /segments | 30 / minute | 15 / minute |
POST /segments/preview | 10 / minute | 5 / minute |
POST /contact-properties | 30 / minute | 15 / minute |
PATCH /contact-properties/:id | 30 / minute | 15 / minute |
DELETE /contact-properties/:id | 30 / minute | 15 / minute |
POST /subscription-topics | 30 / minute | 15 / minute |
PATCH /subscription-topics/:id | 30 / minute | 15 / minute |
DELETE /subscription-topics/:id | 30 / minute | 15 / minute |
POST /subscription-topics/contacts/:contactId/subscriptions | 30 / minute | 15 / minute |
DELETE /subscription-topics/contacts/:contactId/subscriptions/:topicId | 30 / minute | 15 / minute |
POST /suppressions | 30 / minute | 15 / minute |
POST /suppressions/import | 10 / minute | 5 / minute |
DELETE /suppressions/:email | 30 / minute | 15 / minute |
POST /signup-forms | 30 / minute | 15 / minute |
PATCH /signup-forms/:id | 30 / minute | 15 / minute |
DELETE /signup-forms/:id | 30 / minute | 15 / minute |
POST /signup-forms/:id/submit | 10 / minute | 10 / minute per IP address |
POST /mailboxes | 10 / minute | 5 / minute |
PATCH /mailboxes/:id | 10 / minute | 5 / minute |
DELETE /mailboxes/:id | 10 / minute | 5 / minute |
POST /mailboxes/:id/password | 10 / minute | 5 / minute |
POST /mailboxes/:id/shares | 10 / minute | 5 / minute |
DELETE /mailboxes/:id/shares/:targetId | 10 / minute | 5 / minute |
PUT /mailboxes/:id/filters | 10 / minute | 5 / minute |
POST /mailboxes/aliases | 10 / minute | 5 / minute |
DELETE /mailboxes/aliases/:id | 10 / minute | 5 / minute |
ANY /mcp | 120 / minute | 60 / minute |
POST /signup-forms/:id/submit is the public endpoint your website's form posts to, so it is counted per visitor IP address and is the same on every plan. /mcp is one counter for every MCP tool call made with that key.
Rate-limit headers#
Every response from a rate-limited endpoint carries three headers, whether or not the limit was hit:
HTTP/1.1 202 Accepted
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1790676120X-RateLimit-Limitinteger- Requests allowed in the current window for this key and endpoint — already halved on the Free plan.
X-RateLimit-Remaininginteger- Requests left in the window.
0means the next one will be refused. X-RateLimit-Resetinteger- When the window ends, as Unix time in seconds.
Retry-Afterinteger- Sent only on a rate-limit
429: seconds until the window ends.
Endpoints with no per-endpoint limit (most GETs) don't send these headers.
The 429 response#
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790676120
Retry-After: 38
{
"error": "Rate limit exceeded. Please try again later.",
"code": "rate_limit_exceeded"
}The request was not processed — nothing was sent or changed — so it is always safe to repeat it after Retry-After seconds. Requests refused this way still count toward the window, so retrying in a tight loop keeps you limited until the window ends.
A 429 can also mean a sending quota was reached. Each has its own code and no Retry-After, and retrying it soon won't help. Branch on code, not on the error text:
| code | Cause | When it clears |
|---|---|---|
rate_limit_exceeded | Request rate limit on this endpoint. | After Retry-After seconds. |
monthly_limit_exceeded | Free plan used its 3,000 emails for the month. | The 1st of next month (UTC), or on upgrade. |
daily_limit_exceeded | The external-recipient cap for new and Free accounts (the error states the cap that fired). | 24 hours after the first counted send of the window. |
send_limit_exceeded | Sending from new accounts is paused platform-wide (a safety brake). | Automatically; established accounts are unaffected. |
In a POST /emails/batch response, an element refused by a quota carries the same code next to its error.
Over SMTP the same conditions come back as 451 (temporary), so a mail client or MTA retries on its own schedule.
Retrying#
- Honour
Retry-After. Wait that many seconds, then send again. Add a little random jitter if many workers share a key so they don't all return in the same second. - Pace instead of bursting. Spreading 60 sends across the minute never trips the limit; sending 60 in the first second and then waiting works too, but only if you actually wait.
- Tell a rate limit from a quota. Only
rate_limit_exceededhasRetry-After. For the quota codes above, stop and alert a person. - Make sends retry-safe. Put an
idempotency_keyon every send so a retry can never deliver twice — see Idempotency.
The TypeScript, Python and Go SDKs retry 408, 429 and 5xx responses up to 3 times by default, with full-jitter backoff starting at 250 ms. A rate-limit 429 waits its Retry-After instead (up to 60 seconds; longer is raised to your code with the wait on the error). A quota 429 — any of the codes above — is raised at once, never retried:
# -D - prints the response headers; read Retry-After from a 429
curl -sS -D - -o /dev/null -X POST https://api.poststack.dev/emails \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d @email.jsonimport { PostStack, PostStackError } from '@poststack.dev/sdk';
// The SDK already waits out Retry-After (up to 60 s) on a rate-limit 429
// and throws quota 429s at once. This adds a longer, bounded retry on top.
const poststack = new PostStack(process.env.POSTSTACK_API_KEY!);
async function sendWithBackoff(input: Parameters<typeof poststack.emails.send>[0]) {
for (let attempt = 0; ; attempt++) {
try {
return await poststack.emails.send(input);
} catch (err) {
// Only a rate limit clears by waiting. monthly_limit_exceeded,
// daily_limit_exceeded and send_limit_exceeded never do.
const retryable =
err instanceof PostStackError && err.code === 'rate_limit_exceeded';
if (!retryable || attempt >= 5) throw err;
await new Promise((r) => setTimeout(r, (err.retryAfter ?? 15) * 1000));
}
}
}Edge limits#
In front of the API, the load balancer limits each client IP address, whatever credential it uses:
| Paths | Sustained rate | Burst |
|---|---|---|
POST /emails/send, POST /emails/batch | 10 requests / second | 20 |
| Every other API path | 30 requests / second | 50 |
A request refused here gets a 429 with a small HTML body instead of JSON, and no rate-limit headers. Back off for a second or two. You are only likely to see this from many parallel workers behind one IP.
Request bodies over 36 MB are refused at the edge with 413. That ceiling sits above the attachment limits below so that a message within them is never cut off by it.
Sending quotas#
Monthly emails
Every accepted message counts once toward the calendar month (UTC), however many recipients it has. A batch of 100 counts 100. Test-mode sends and idempotent replays don't count.
| Plan | Included per month | Past the allowance |
|---|---|---|
| Free | 3,000 | Sends are refused with 429 until the month ends |
| Starter | 10,000 | Keeps sending; billed at €0.25 per 1,000 |
| Pro | 50,000 | Keeps sending; billed at €0.25 per 1,000 |
| Scale | 100,000 | Keeps sending; billed at €0.25 per 1,000 |
| Enterprise | Unlimited | — |
Current usage is on the Settings → Billing page.
New-account external-recipient cap
To protect the shared sending IPs, a send with at least one recipient outside the domains your team has added counts against a daily cap. One send is one unit, no matter how many external recipients it has; it applies per team across all keys and SMTP, and test-mode sends are exempt.
| Who | Cap | What happens past it |
|---|---|---|
| Any account in its first hour | 10 external sends | Sending is put on hold for a safety review (403); contact support to lift it. |
| Free plan, and paid plans in their first 72 hours | 50 external sends per 24 hours | 429 for the rest of the window. In the account’s first 24 hours the first breach puts sending on hold for review (403) instead. |
| Paid plans after 72 hours | No fixed cap | A send far above anything the account has sent before is held for review (403). |
The 24-hour window is fixed: it starts at the first counted send and the whole count resets when it ends.
Past the cap the 429 carries code: "daily_limit_exceeded" and its message states the cap that fired (10 in the first hour, 50 after).
SMTP mailboxes
Mail submitted over SMTP with a mailbox login (not an API key) is limited to 60 messages per hour per mailbox; past that the relay answers Rate limit exceeded. Try again later. Ten failed SMTP logins from one IP address or for one username within 15 minutes block further attempts for the rest of that window. SMTP sends authenticated with an API key have no per-message rate limit but count toward the same quotas as the API.
Account limits#
| Resource | Free | Starter | Pro | Scale | Enterprise | Past it |
|---|---|---|---|---|---|---|
| Domains | 1 | 3 | 10 | 1,000 | Unlimited | 403 Domain limit reached for … plan |
| API keys | 5 | 10 | 50 | 1,000 | Unlimited | 403 API key limit reached for … plan |
| Webhooks | 20 | 20 | 20 | 20 | 20 | 403 Webhook limit reached (20 per team) |
| Email log retention | 14 days | 14 days | 30 days | 90 days | Unlimited | Older emails are deleted |
Per-request limits#
Limits on the size of a single send. Schema limits answer 400; the checks that run after validation answer 422. Both name the limit in error.
| Limit | Value | Status |
|---|---|---|
| Recipients per email (to + cc + bcc) | 50 in total | 422 |
| Emails per batch request | 100 | 400 |
| Attachments per email | 10 | 400 |
| Size of one attachment (decoded) | 10 MB | 422 |
| Total attachments per email (decoded) | 25 MB | 422 |
| Tags per email | 10, each up to 64 characters | 400 |
| Subject | 998 characters | 400 |
| Custom header value | 1,024 characters | 400 |
| Contacts per import request | 10,000 | 400 |
Troubleshooting#
I get 429 on the first request of the day
Check the code. If it isn't rate_limit_exceeded, it is a quota — most often monthly_limit_exceeded (the Free plan's 3,000) or daily_limit_exceeded (the external-recipient cap) — and the table under The 429 response says when it clears.
X-RateLimit-Limit is half of what this page says
Your team is on the Free plan, or has no subscription. Paid plans get the full figure within five minutes of upgrading.
I got a 429 with an HTML body
That came from the edge limit on your IP address, not from the API. Reduce parallelism from that host, or spread workers across the minute.