Rate limits
Each key gets 60 requests per calendar minute. Every counted response carries X-RateLimit headers, and a 429 carries Retry-After in seconds.
The limit
60 requests per minute, per key. The window is the calendar minute in UTC (a fixed window, not a sliding one): the count starts at zero at the top of every minute.
- The limit is per key, not per workspace. Two integrations with their own keys each get 60 a minute.
- Because the window is fixed, a burst that straddles a minute boundary can get up to 120 requests through in a few seconds. Don’t rely on that; pace your requests instead.
- Every request made with a working key counts, including ones that end in an error such as
404 not_foundor422 already_assigned. - Requests refused before they reach the limiter don’t count: a missing, malformed, unknown or revoked key, a creator_not_admin key, a key in the URL, an unknown endpoint, a wrong method, a body that isn’t JSON, or a parameter that fails validation.
Headers
Every response that counted against the limit carries these headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed per minute: 60. |
X-RateLimit-Remaining |
Requests left in the current minute. |
X-RateLimit-Reset |
Unix time, in seconds, when the current minute ends and the count resets. |
Responses that didn’t count (see above) don’t carry them.
curl -i https://app.sopcompass.com/api/v1/boards \
-H "Authorization: Bearer $SOPCOMPASS_API_KEY"HTTP/2 200
content-type: application/json; charset=utf-8
cache-control: no-store
x-sopcompass-api-version: v1
x-ratelimit-limit: 60
x-ratelimit-remaining: 57
x-ratelimit-reset: 1790000040Over the limit: 429
The 61st request in a minute, and every one after it until the minute ends, gets 429 rate_limited with a Retry-After header: the number of seconds until the next minute starts (at least 1).
HTTP/2 429
retry-after: 17
x-ratelimit-limit: 60
x-ratelimit-remaining: 0
x-ratelimit-reset: 1790000040{
"error": {
"code": "rate_limited",
"message": "Too many requests. The limit is 60 a minute per key."
}
}Wait Retry-After seconds, then retry the same request. A 429 changed nothing, so retrying a POST after a 429 is safe.
Back off and retry
Retry 429 after Retry-After. Retry 500 internal_error with exponential backoff and jitter. Don’t retry any other 4xx: the request itself needs fixing.
const BASE = "https://app.sopcompass.com/api/v1";
async function sopcompass(path, init = {}, attempt = 0) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.SOPCOMPASS_API_KEY}`,
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
const retryable = res.status === 429 || res.status >= 500;
if (retryable && attempt < 5) {
const retryAfter = Number(res.headers.get("retry-after"));
const backoff = Math.min(30, 2 ** attempt) + Math.random();
const waitSeconds = res.status === 429 && retryAfter > 0 ? retryAfter : backoff;
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
return sopcompass(path, init, attempt + 1);
}
const body = await res.json();
if (!res.ok) {
const err = new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
err.status = res.status;
err.code = body.error.code;
throw err;
}
return body;
}import os
import random
import time
import requests
BASE = "https://app.sopcompass.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SOPCOMPASS_API_KEY']}"
class SOPCompassError(Exception):
def __init__(self, status, code, message):
super().__init__(f"{status} {code}: {message}")
self.status = status
self.code = code
def sopcompass(method, path, max_attempts=6, **kwargs):
for attempt in range(max_attempts):
res = session.request(method, f"{BASE}{path}", timeout=30, **kwargs)
retryable = res.status_code == 429 or res.status_code >= 500
if retryable and attempt < max_attempts - 1:
retry_after = res.headers.get("Retry-After")
if res.status_code == 429 and retry_after:
wait = int(retry_after)
else:
wait = min(30, 2 ** attempt) + random.random()
time.sleep(wait)
continue
body = res.json()
if not res.ok:
raise SOPCompassError(res.status_code, body["error"]["code"], body["error"]["message"])
return bodyStay under the limit
- Read
X-RateLimit-Remaining. When it reaches0, pause untilX-RateLimit-Resetinstead of sending a request you know will fail. - Use
limit=100onGET /sopsandGET /assignments. Fewer, larger pages use fewer requests. - Sync incrementally. Poll
GET /sopswithupdated_sincerather than re-reading everything. See Filtering and updated_since. - Fetch full SOPs only when they changed.
GET /sops/{id}costs one request per SOP; compareupdated_atfirst. - One key per integration. Each gets its own 60 a minute, and a runaway script can’t starve your other integrations.