Core concepts
Errors
Errors are JSON, never HTML. Every error response includes a stable error code in the body and a meaningful HTTP status. The error code is what your client should branch on; the HTTP status is what your monitoring should aggregate.
Shape
The body always carries an error string. Many responses also include a reason and contextual fields when they help. Always include the response X-Request-Id header in support tickets — we resolve issues 10x faster with it.
json
{
"error": "api_key_unauthorized",
"reason": "expired"
}json
{
"error": "missing_scope",
"required": "payments:write"
}json
{
"error": "rate_limit_exceeded",
"window": "hour",
"limit": 2000,
"retryAfter": 1840
}json
{
"error": "validation_failed",
"issues": [
{ "path": ["amountCents"], "message": "Expected integer, received number" },
{ "path": ["currency"], "message": "Required" }
]
}Codes
| HTTP | Reason | Meaning | Retry? |
|---|---|---|---|
| 400 | validation_failed | Body or query failed schema validation. The response includes the offending field path. | no |
| 400 | invalid_json | Body could not be parsed as JSON. Check Content-Type and trailing commas. | no |
| 401 | missing | No API key was sent on the request. | no |
| 401 | malformed | The key didn't match the expected wsk_<env>_<prefix>_<secret> format. | no |
| 401 | not_found | Prefix didn't resolve to an active key. Was it revoked or rotated? | no |
| 401 | revoked | The key exists but has been revoked. | no |
| 401 | expired | The key passed its expiresAt date. | no |
| 401 | invalid_secret | Prefix matched, but the secret didn't. | no |
| 403 | missing_scope | Key authenticated, but the granted scopes don't include what this endpoint requires. | no |
| 403 | ip_not_allowed | Enterprise key with an IP allowlist, called from outside the allowlist. | no |
| 403 | plan_does_not_support_api_keys | Org's current plan tier does not include API access (trial/core). | no |
| 404 | not_found | Resource doesn't exist or is not visible to this key's organization. | no |
| 409 | idempotency_conflict | Same Idempotency-Key was used with a different request body. The original response is preserved untouched. | no |
| 409 | state_conflict | The resource is in a state that doesn't allow this transition (e.g. cancelling a paid invoice). | no |
| 422 | invalid_scope | Tried to grant a scope that doesn't exist when minting a key or grant. | no |
| 422 | max_keys_reached | Org has hit the active-keys cap. Revoke an unused key first. | no |
| 429 | rate_limit_exceeded | Hit one of the four rate-limit windows. | after Retry-After |
| 500 | internal_server_error | Server-side bug. We've been alerted. | yes |
| 502 | upstream_error | An upstream dependency (PSP, KYB provider, document signer) returned an error we couldn't recover from. | yes |
| 503 | maintenance | Scheduled maintenance window. Always announced 48h in advance via status.worksible.com. | after Retry-After |
| 504 | timeout | Internal timeout. The work may or may not have committed; check the resource state and retry idempotently if needed. | yes |
Retry strategy
- 5xx and 504: retry with exponential backoff (1s → 2s → 4s → 8s → 16s) plus jitter. Cap at 5 attempts. Always send an Idempotency-Key on writes so you don't double-create.
- 429: sleep for at least the number of seconds in
Retry-Afterbefore retrying. Add 10–30% jitter. - 503 maintenance: respect the window. We post the schedule on status.worksible.com and email every key's billing contact 48 hours before.
- 4xx other than 429: do not retry. Fix the request, scope, or data, then send a new one.
node.js · production-grade retry
async function callWorksible(url, init = {}) {
const headers = {
"X-API-Key": process.env.WORKSIBLE_API_KEY,
"Idempotency-Key": init.idempotencyKey ?? crypto.randomUUID(),
"Content-Type": "application/json",
...init.headers,
};
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(url, { ...init, headers });
if (res.ok) return res;
const status = res.status;
if (status >= 500 || status === 504) {
const wait = Math.min(2 ** attempt, 16) + Math.random();
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
if (status === 429) {
const ra = Number(res.headers.get("Retry-After") ?? 30);
await new Promise((r) => setTimeout(r, ra * 1000 * (1 + Math.random() * 0.3)));
continue;
}
// 4xx — don't retry. Surface the error to the caller.
throw Object.assign(new Error(`worksible ${status}`), {
status,
body: await res.json().catch(() => null),
requestId: res.headers.get("X-Request-Id"),
});
}
throw new Error("worksible: max retries reached");
}Logs
Every error your client receives is also stored under your API key's log. Open the key in Settings → API keys → Logs and filter by status code to debug without enabling client-side tracing. The dashboard shows the same X-Request-Id you got on the response so you can trace one request end-to-end.
Need help?
For non-trivial bugs, email developers@worksible.com with the request id and the timestamp. We aim to respond within one business day. Production incidents page our on-call via status.worksible.com.