Skip to content

Errors & Rate Limits

All errors follow the same shape. Use the code field for programmatic handling.

Error shape

Json
{
  "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

StatusCodeMeaning
200OK—Request succeeded.
201Created—Resource created. Returned by POST endpoints.
204No Content—Resource deleted. Returned by DELETE endpoints.
400Bad Requestinvalid_requestBody / query / params failed Zod validation. The details.issues field lists offending fields.
400Bad Requestinvalid_idempotency_keyIdempotency-Key header was supplied but malformed (must be 8–200 chars).
401UnauthorizedunauthorizedMissing or invalid API key.
401Unauthorizedkey_expiredAPI key has expired.
403Forbiddeninsufficient_scopeKey lacks the scope required for this method (read vs write).
404Not Foundnot_foundResource doesn't exist or doesn't belong to your org.
409Conflictidempotency_conflictIdempotency-Key was reused with a different request body.
422Unprocessableinvalid_transitionSOP version status transition is not allowed from the current state.
429Too Many Requestsrate_limitedPer-key rate limit exceeded. See Retry-After header.
500Internal Server Errorinternal_errorServer-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.

Json
{
  "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.

Shell
# 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:

Javascript
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");
}

Recover without duplicating work

Save the response status and X-Request-Id without logging the authorization header. Treat validation and permission failures as decisions to correct, not as requests to retry indefinitely.

For a retried POST, keep the same Idempotency-Key and the same body for that logical attempt. A different operation needs a new key. Follow documented retry guidance and inspect the result before issuing another mutation.