Rate limits
Limits are applied per credential, under an account ceiling, and per route category, over a rolling 60-second window.
| Category | Routes | Limit |
|---|---|---|
| Sending messages | POST /messages and POST /v1/.../messages | 500 req/min |
| Media upload | POST /v1/.../media | 60 req/min |
| Reads | everything else (/templates, /events, other /v1/*) | 60 req/min |
The values in the table are the defaults. Your plan may set its own limits per category, and then the plan's apply.
Not limited: /plans and /health.
Per key, under an account ceiling
Each API key has its own bucket, sized by the plan — and so does each person signed in to
the dashboard. An integration stuck in a loop exhausts only its own quota: the WordPress
plugin's key hammering /v1 does not leave your ERP's key on 429.
Above the keys' buckets, the account has a ceiling per category, 3 times the plan's limit by default. That is what keeps extra keys from multiplying the quota without bound: with sending at 500/min, a single key reaches 500 sends a minute, and all of the account's keys together, 1,500.
The dashboard stays outside that ceiling: a signed-in person spends only their own bucket. A key stuck in a loop can exhaust the account's ceiling, but it does not lock you out of the dashboard — which is where you find and revoke that key.
Control headers
Every response on a limited route carries the current state of the bucket:
| Header | Contents |
|---|---|
X-RateLimit-Category | send, media or default |
X-RateLimit-Scope | The bucket described: key (API key), session (dashboard) or account |
X-RateLimit-Limit | That bucket's ceiling for the window |
X-RateLimit-Remaining | How much is left |
On a successful response, the headers describe your credential's bucket. On a 429,
they describe the bucket that refused — which may be the account's.
HTTP/1.1 201 Created
X-RateLimit-Category: send
X-RateLimit-Scope: key
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 487
Watch X-RateLimit-Remaining and slow down before you hit the ceiling — it's cheaper
than handling 429.
When the limit is hit
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Category: send
X-RateLimit-Scope: key
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 0
{
"error": {
"message": "Rate limit exceeded for send requests with this credential. Try again in 12s.",
"type": "rate_limit",
"scope": "key"
}
}
The error.scope field repeats X-RateLimit-Scope. When it says account, your key
still had quota: it was the account's other keys, added up, that used up the ceiling.
Spreading the calls over more keys does not help — only waiting does.
The Retry-After header says exactly how many seconds to wait. Respect it — pushing
earlier only burns quota.
Handling the 429
async function send(payload, attempt = 0) {
const response = await fetch('https://api.joinotify.com/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.JOINOTIFY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < 3) {
const wait = Number(response.headers.get('retry-after') ?? 5);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
return send(payload, attempt + 1);
}
return response.json();
}
For bulk sending, prefer a rate-controlled queue (say 8 messages per second, well under 500/min) over firing everything and reacting to the error.
Meta's limits apply too
The numbers above are Joinotify's limits. Underneath, Meta enforces its own — the cap on business-initiated messages in 24 hours, which varies with your number's quality and verification tier.
When it is Meta that refuses, the response comes back as meta_error with their original
body in error.meta, not as rate_limit.
Need more?
Limits are configurable per account. If your volume demands more, contact support with an estimate of your peak per minute.