Errors & Rate Limits
All errors follow the same shape. Use the code field for programmatic handling.
Error shape
{
"error": "Invalid API key",
"code": "unauthorized",
"requestId": "f243456d-1f26-4716-833f-c4804aa38c2a"
}The error field is human-readable. The code field is stable and machine-readable — switch on this.
HTTP status codes
| Status | Code | Meaning |
|---|---|---|
| 200OK | — | Request succeeded. |
| 201Created | — | Resource created. Returned by POST endpoints. |
| 204No Content | — | Resource deleted. Returned by DELETE endpoints. |
| 400Bad Request | invalid_request | Body / query / params failed Zod validation. The details.issues field lists offending fields. |
| 400Bad Request | invalid_idempotency_key | Idempotency-Key header was supplied but malformed (must be 8–200 chars). |
| 401Unauthorized | unauthorized | Missing or invalid API key. |
| 401Unauthorized | key_expired | API key has expired. |
| 403Forbidden | insufficient_scope | Key lacks the scope required for this method (read vs write). |
| 404Not Found | not_found | Resource doesn't exist or doesn't belong to your org. |
| 409Conflict | idempotency_conflict | Idempotency-Key was reused with a different request body. |
| 422Unprocessable | invalid_transition | SOP version status transition is not allowed from the current state. |
| 429Too Many Requests | rate_limited | Per-key rate limit exceeded. See Retry-After header. |
| 500Internal Server Error | internal_error | Server-side error. Quote the requestId when contacting support. |
Validation errors
Bad input gets a 400 with code: "invalid_request". The details.issues field is the flattened Zod error tree — surface it directly to the user or log it for debugging.
{
"error": "Invalid body",
"code": "invalid_request",
"details": {
"issues": {
"fieldErrors": {
"title": ["Required"],
"nodeType": ["Required"]
},
"formErrors": []
}
},
"requestId": "..."
}Idempotency
POSTs accept an Idempotency-Key header (8–200 chars). The server caches the response for 24 hours, keyed on apiKeyId + Idempotency-Key. Replays of the same key + body return the cached status and JSON, plus an Idempotent-Replay: true header. Reusing a key with a different body returns 409 idempotency_conflict. PATCH and DELETE are inherently idempotent on the same id, so the header is optional there.
# First call — creates the resource and caches the response for 24h.
curl -X POST https://sopflow.io/api/v1/nodes \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9c3b7d6e-4a4d-4e2c-9b6f-cb1e2d3f4567" \
-d '{"parentId":null,"title":"Engineering","nodeType":"department"}'
# Same key + same body → identical 201 response, with Idempotent-Replay: true header.
# Same key + DIFFERENT body → 409 idempotency_conflict.Use a fresh UUID per logical operation (don't reuse one across batches). The cache is process-local — a server restart drops it, but that window is small enough that retry storms aren't a concern at our scale.
Request IDs
Every response carries an X-Request-Id header, and every error envelope echoes it back as requestId. Quote that ID when reporting issues so we can grep our journal for the exact request — much faster than describing the symptom. You can also send your own (a UUID, your trace ID, etc.) on the way in and we'll propagate it back.
Rate limits
Limits are per API key: 600 req/min overall and an additional 120 req/min on writes (POST / PATCH / DELETE). When either bucket is exhausted, the response is 429 rate_limited with a Retry-After header (seconds to wait).
Buckets are per-key, not per-org, so a runaway script on one key won't starve another. Pagination headers X-Page-Limit, X-Has-More are returned on every list endpoint.
Retry with exponential backoff
Retry only on 429 and 5xx responses. Use exponential backoff with jitter to avoid thundering herd:
async function fetchWithRetry(url, options, maxRetries = 3) {
let delay = 500; // ms
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(url, options);
if (res.status === 429) {
// Rate limited — wait and retry with exponential backoff
const retryAfter = res.headers.get("Retry-After");
const wait = retryAfter ? Number(retryAfter) * 1000 : delay;
await new Promise((r) => setTimeout(r, wait));
delay *= 2;
continue;
}
return res; // any other status — return to caller
}
throw new Error("Max retries exceeded");
}