Rate Limiting
The API enforces a per-org sliding-window rate limit. Every response — successful or not — carries the current state of your budget so you can pace requests proactively instead of reacting to 429s.
The numbers
| Key environment | Default limit |
|---|---|
| Live | 600 requests / minute |
| Test | 60 requests / minute |
Limits are shared across all keys in an org — if one key burns the budget, all keys in the org see 429s until the window slides. Need a higher ceiling for a specific use case? Contact support.
Headers on every response
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 412
X-RateLimit-Reset: 37X-RateLimit-Limit— the ceiling that applies to this request, in requests per minute.X-RateLimit-Remaining— how many more requests you can make in the current window.X-RateLimit-Reset— seconds until the window slides far enough that you’d be at full capacity again.
These are advisory — a healthy client paces itself off Remaining and only falls back to retry-on-429 as a safety net.
What happens at the ceiling
When you’ve consumed the budget, the next request returns:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 12
{
"type": "https://api.sendops.dev/errors/rate_limit",
"title": "Rate limit exceeded",
"status": 429,
"detail": "Too many requests for this organization. Retry in 12 seconds.",
"retry_after": 12
} Retry-After is the canonical signal — sleep at least that many seconds before retrying. The same value also appears in X-RateLimit-Reset and the problem body’s retry_after.
Handling 429 in code
The safe pattern is exponential backoff with jitter, honouring Retry-After as the minimum delay.
async function callSendOps(path) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`https://api.sendops.dev${path}`, {
headers: { Authorization: `Bearer ${process.env.SENDOPS_API_KEY}` },
})
if (res.status !== 429) return res
const retryAfter = Number(res.headers.get("Retry-After") ?? 1)
const jitter = Math.random() * 250
await new Promise(r => setTimeout(r, retryAfter * 1000 + jitter))
}
throw new Error("rate-limited after 4 attempts")
} A few rules:
- Never retry faster than
Retry-After. Doing so spends budget without making progress and slows your own recovery. - Cap retries — runaway loops are the most common reason a service stays rate-limited indefinitely.
- Add jitter so multiple clients don’t lock-step into the same retry instant.
Auth-failure limits (IP-based)
Unauthenticated traffic and traffic from invalid keys is rate-limited per IP, with a tighter ceiling, so we don’t get DDoS’d by credential-stuffing or scanner probes. You should never see this limit in normal use — it only trips when you’re sending many requests with bad keys from the same source.
Repeated 401s will trip an IP block
If your client retries indefinitely on 401, the originating IP will hit the IP-based auth limit and start receiving 429s on every request — including ones with valid keys. Fix the underlying auth issue before retrying.
Test vs live quotas
Test keys run at 10% of the live limit. That’s intentional — test keys are for CI smoke checks and unit tests, not load tests. If you find yourself wanting to load-test against test keys, use live keys against a non-production SendOps account instead.
Tips for high-volume readers
Most readers won’t come close to the limit, but if you’re paginating large message lists or polling reports on a tight loop:
- Cache responses. Reports are computed from event data that updates within seconds — caching for 30–60 seconds on your side is usually safe and reduces request count dramatically.
- Use the largest
limityou can. Lists supportlimitup to 200 on most endpoints. Fewer requests, same data. - Filter server-side. Don’t pull all messages and filter client-side; use the available query parameters.
- Subscribe instead of poll where possible. For event-driven needs, configure a webhook in the dashboard rather than polling the API.