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

HTTPReasonMeaningRetry?
400validation_failedBody or query failed schema validation. The response includes the offending field path.no
400invalid_jsonBody could not be parsed as JSON. Check Content-Type and trailing commas.no
401missingNo API key was sent on the request.no
401malformedThe key didn't match the expected wsk_<env>_<prefix>_<secret> format.no
401not_foundPrefix didn't resolve to an active key. Was it revoked or rotated?no
401revokedThe key exists but has been revoked.no
401expiredThe key passed its expiresAt date.no
401invalid_secretPrefix matched, but the secret didn't.no
403missing_scopeKey authenticated, but the granted scopes don't include what this endpoint requires.no
403ip_not_allowedEnterprise key with an IP allowlist, called from outside the allowlist.no
403plan_does_not_support_api_keysOrg's current plan tier does not include API access (trial/core).no
404not_foundResource doesn't exist or is not visible to this key's organization.no
409idempotency_conflictSame Idempotency-Key was used with a different request body. The original response is preserved untouched.no
409state_conflictThe resource is in a state that doesn't allow this transition (e.g. cancelling a paid invoice).no
422invalid_scopeTried to grant a scope that doesn't exist when minting a key or grant.no
422max_keys_reachedOrg has hit the active-keys cap. Revoke an unused key first.no
429rate_limit_exceededHit one of the four rate-limit windows.after Retry-After
500internal_server_errorServer-side bug. We've been alerted.yes
502upstream_errorAn upstream dependency (PSP, KYB provider, document signer) returned an error we couldn't recover from.yes
503maintenanceScheduled maintenance window. Always announced 48h in advance via status.worksible.com.after Retry-After
504timeoutInternal 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-After before 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.