Rate limits
Per-endpoint-class budgets (read, write, quote), the X-RateLimit-* headers, and how to handle a 429 with correct retries.
Every request is classified into one of three endpoint classes and counted against that class’s per-minute budget. The classes are independent: heavy quoting never starves your label purchases, and vice versa.
| Class | What falls in it |
|---|---|
read |
GET, HEAD, and OPTIONS |
write |
POST, PUT, PATCH, and DELETE that aren’t quotes |
quote |
Rate quoting: POST /v1/rates, POST /v1/rates/carrier/:carrierCode, and GET /v1/shipments/:id/rates |
How your requests are bucketed
The window is 60 seconds and is shared across every API server.
| Traffic | Metered by | Per-minute budget |
|---|---|---|
Live key (sk_live_) |
Organization, per class | Your plan’s budgets (below) |
Test key (sk_test_) |
Organization, per class, in an isolated bucket | 25% of the plan, floor of 10/min |
| Dashboard session | Session, per class | read 300 · write 100 · quote 30 |
| Unauthenticated | IP, per class | read 60 · write 30 · quote 10 |
All of an organization’s keys share the same budget. Test traffic gets its own buckets: what you spend in the sandbox never eats into production headroom, and vice versa.
Per-plan budgets
For live keys, requests per minute:
| Plan | read |
write |
quote |
|---|---|---|---|
| Free | 60 | 30 | 20 |
| Growth | 300 | 150 | 60 |
| Scale | 1,000 | 500 | 200 |
| Enterprise | 5,000 | 2,500 | 1,000 |
sk_test_ keys get 25% of each number, with a floor of 10/min. For example: Free test quote is 10, and Scale test read is 250.
Read the headers
Every throttled response carries these headers, unsuffixed:
| Header | Description |
|---|---|
X-RateLimit-Limit |
The budget for the class this request landed in |
X-RateLimit-Remaining |
Requests remaining in that bucket this minute |
X-RateLimit-Reset |
When the bucket resets, in Unix epoch seconds |
Retry-After |
Seconds until you can retry (on 429 only) |
The headers describe the class of the request that returned them: a POST and a GET sent in the same second can legitimately report different limits.
Handle a 429
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests",
"details": { "retryAfter": 30 }
}
}
Once you’re over, requests are rejected until the 60-second window rolls; there is no extra penalty or extended block. Honor Retry-After and apply exponential backoff:
async function withRetry(fn, maxAttempts = 5) {
for (let attempt = 1; ; attempt++) {
const res = await fn();
if (res.status !== 429 || attempt === maxAttempts) return res;
const retryAfter = Number(res.headers.get("Retry-After") ?? 1);
const backoff = retryAfter * 1000 * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, backoff));
}
}