Error Handling
HTTP status codes, conflict codes, and retry guidance
The Servicebay API uses standard HTTP status codes and returns a consistent JSON envelope on every error. Your client only needs one parsing path.
Error envelope
{
"success": false,
"error": "Description of what went wrong",
"code": "insufficient_scope",
"requiredScopes": ["tickets:write"],
"data": { "gate": "completion", "outstanding": 2 },
"requestId": "iad1::abc123"
}| Field | When it appears |
|---|---|
error | Always |
code | Permission failures and every 409 |
requiredScopes | 403 insufficient_scope |
data | Some 409s (form gates, customer cascade) |
requestId | When the handler has one — always quote it to support on 5xx |
JSON request bodies are capped at 1 MB. Date fields on write schemas
must be ISO 8601 datetimes with an offset (for example
2026-09-20T10:00:00.000Z). Path IDs and startAfter must match
[A-Za-z0-9_-]{1,128}.
Status codes
| Status | Meaning |
|---|---|
200 | OK |
201 | Created |
400 | Bad request — validation, bad JSON, bad limit / dates / path IDs, unknown job status, missing Firestore index |
401 | Unauthorized — missing or invalid credential (invalid_token) |
403 | Forbidden — scope, use, IP, org mismatch, not Pro/trial, inactive membership |
404 | Not found |
409 | Conflict — business rule (form gates, series shape, quote already invoiced, …) |
413 | Payload too large — body exceeds 1 MB |
415 | Unsupported media type — Content-Type must be application/json (or +json) |
429 | Rate limited |
500 | Unexpected server error |
502 | Portal → OAuth Worker proxy failed (Connected Apps / OAuth Clients) |
503 | Service unavailable — production rate-limiter backend missing, or OAuth introspection failed |
504 | Portal → OAuth Worker timed out |
400 Bad Request
| Cause | Typical message |
|---|---|
| Invalid JSON | Request body must be valid JSON. |
Bad limit | limit must be an integer between 1 and 100. (values above 100 are clamped, not rejected) |
Bad from / to | {name} must be an ISO 8601 date. |
Bad path ID or startAfter | Invalid {name}. |
| Unknown job status | Unknown job status '{status}'. |
| Missing composite index | This filter combination is not supported yet (a database index is still being provisioned). Try a single filter, or contact support. |
| Zod / field validation | Schema issue list or a field-specific message |
Oversized or unknown nested keys on line items and discounts also return
400.
401 Unauthorized
{
"success": false,
"error": "Missing credentials. Provide an X-API-Key header or an OAuth Bearer token."
}Other messages: Invalid API key format., This API key has been revoked or paused., This API key has expired., Invalid or expired access token.
Header (always on 401):
WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://developer.servicebay.io/.well-known/oauth-protected-resource"Repeated failures from the same IP (10/min) start returning 429
instead.
403 Forbidden
Insufficient scope
{
"success": false,
"error": "Insufficient scope. This request requires 'tickets:write'.",
"code": "insufficient_scope",
"requiredScopes": ["tickets:write"]
}WWW-Authenticate: Bearer error="insufficient_scope", scope="tickets:write", resource_metadata="https://developer.servicebay.io/.well-known/oauth-protected-resource"Quote convert requires both quotes:write and invoices:write.
DELETE /customers/{id} with jobs and default cascade=true requires
both customers:delete and tickets:delete
(requiredScopes: ["customers:delete","tickets:delete"]).
Other 403 codes
code | Meaning |
|---|---|
unknown_resource | Path is not in the deny-by-default route map |
use_not_allowed | REST-only key used at MCP, or MCP-only key used on REST |
ip_not_allowed | Caller IP is outside the key's CIDR allowlist |
Organisation mismatch
{
"success": false,
"error": "You do not have access to this organisation"
}403 Forbidden — Pro subscription required
{
"success": false,
"error": "This organisation does not have an active Pro subscription. Upgrade to Pro to use the Servicebay API."
}Paid Basic orgs are locked out. In-app trial counts as Pro for the API and portal. No new key is needed after an upgrade.
OAuth membership revoked
{
"success": false,
"error": "The user who authorised this connection is no longer an active admin of this organisation. Reconnect the app."
}The grant is then auto-revoked so it disappears from Connected Apps.
Portal management without TOTP returns 403 with
Two-factor authentication is required to use the developer portal. ….
404 Not Found
{
"success": false,
"error": "Customer not found"
}/customers/lookup returning 404 means no match — treat it as "no
result", not a client bug.
409 Conflict
Every 409 is a ConflictError and includes code (from
CONFLICT_CODES) plus optional data:
code | When | data |
|---|---|---|
FORM_GATE_CREATE | Creating a job in a gated status | { "status" } |
FORM_GATE_TRANSITION | Moving a job into a gated status with open form counters | { "gate": "start" | "completion", "outstanding" } |
FORM_GATE_DELETE | Deleting a job (or cascading a customer) with open form counters | Customer cascade: { "ticketIds": […] }. Job delete: no data |
FORM_GATE_INVOICE | Creating/updating an invoice or converting a quote for a job with openInvoiceFormCount > 0 | { "outstanding" } |
CUSTOMER_HAS_TICKETS | DELETE customer with ?cascade=false while jobs exist | { "ticketIds": […] } |
LAST_STATUS | Deleting the organisation's last job status | — |
STATUS_IN_USE | Deleting a job status still used by a ticket | — |
STATION_IN_USE | Deleting a station assigned to an active (non-closed) job | — |
CALENDAR_EVENT_EXISTS | Second calendar event for the same job | — |
PAYMENT_ACCOUNTING_MANAGED | Mutating a Stripe- or accounting-linked payment | — |
QUOTE_ALREADY_INVOICED | POST …/quotes/{id}/convert when already converted (pass ?force=true) | — |
RECURRING_VISIT | Rescheduling a generated series visit via the calendar | — |
SERIES_SHAPE_CHANGE | Recurring-schedule edit that changes series shape without ?resync=thisAndFuture | — |
OCCURRENCE_WORKED | Skipping a visit that already has work on the job | — |
OCCURRENCE_NOT_GENERATED | Move/resize of a visit that has no job yet | — |
SCHEDULE_IN_USE | Deleting a recurring schedule that still has occurrences | — |
413 / 415
413 — Request body too large (max 1 MB). Checked from
Content-Length and from the decoded body.
415 — Content-Type must be application/json. Required on POST / PUT
/ PATCH. +json suffixes are accepted.
429 Too Many Requests
{
"success": false,
"error": "Rate limit exceeded. Please wait before making more requests."
}REST responses include X-RateLimit-* and Retry-After (seconds
until the window resets):
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705325400
Retry-After: 12Failed-auth (10/min per IP) uses
Too many failed authentication attempts. Please slow down.
The OAuth Worker (/authorize, /token, /revoke, /register) returns
{ "error": "temporarily_unavailable" } with Retry-After.
A limiter outage is 503, not 429. See
Rate Limiting.
500 / 502 / 503 / 504
| Status | Meaning | Retry? |
|---|---|---|
500 | Unexpected error. Body includes requestId (Vercel x-vercel-id, else a UUID) | Yes, with backoff. Quote requestId to support |
502 | Portal could not reach the OAuth Worker | Yes, briefly |
503 | Production Upstash unset (Service temporarily unavailable.), or OAuth introspection unavailable — Worker down / 5xx / 429 / secret mismatch (Unable to validate access token. Please try again., no authfail charge) | Yes. Do not treat as a client bug |
504 | Portal → Worker timed out | Yes, after a pause |
Functions are pinned to region iad1 with maxDuration 60 s. A
platform timeout surfaces as 504 from Vercel.
Retry guidance
- Do not retry
400,401,403,404,409,413,415— fix the request or the credential - Retry
429afterRetry-After(seconds) orX-RateLimit-Reset - Retry
500/502/503/504with exponential backoff (cap at a few attempts). Idempotent reads are safe; POSTs that allocate a job / invoice / quote number must not be blindly replayed if the first attempt may have succeeded (the 201 can land without the calendar event — retrying create allocates a new id)
async function makeApiRequest(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
...options.headers,
},
});
const data = await response.json();
if (!data.success) {
const err = new Error(data.error || "Unknown error");
err.status = response.status;
err.code = data.code;
err.requiredScopes = data.requiredScopes;
err.requestId = data.requestId;
if (response.status === 429) {
err.resetAt = response.headers.get("X-RateLimit-Reset");
}
throw err;
}
return data.data;
}import requests
def make_api_request(method, url, **kwargs):
headers = {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
**kwargs.pop("headers", {}),
}
res = requests.request(method, url, headers=headers, **kwargs)
body = res.json()
if not body.get("success"):
raise RuntimeError(
f"{res.status_code} {body.get('error')} "
f"code={body.get('code')} requestId={body.get('requestId')}"
)
return body["data"]