Docs

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"
}
FieldWhen it appears
errorAlways
codePermission failures and every 409
requiredScopes403 insufficient_scope
dataSome 409s (form gates, customer cascade)
requestIdWhen 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

StatusMeaning
200OK
201Created
400Bad request — validation, bad JSON, bad limit / dates / path IDs, unknown job status, missing Firestore index
401Unauthorized — missing or invalid credential (invalid_token)
403Forbidden — scope, use, IP, org mismatch, not Pro/trial, inactive membership
404Not found
409Conflict — business rule (form gates, series shape, quote already invoiced, …)
413Payload too large — body exceeds 1 MB
415Unsupported media type — Content-Type must be application/json (or +json)
429Rate limited
500Unexpected server error
502Portal → OAuth Worker proxy failed (Connected Apps / OAuth Clients)
503Service unavailable — production rate-limiter backend missing, or OAuth introspection failed
504Portal → OAuth Worker timed out

400 Bad Request

CauseTypical message
Invalid JSONRequest body must be valid JSON.
Bad limitlimit 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 startAfterInvalid {name}.
Unknown job statusUnknown job status '{status}'.
Missing composite indexThis filter combination is not supported yet (a database index is still being provisioned). Try a single filter, or contact support.
Zod / field validationSchema 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

codeMeaning
unknown_resourcePath is not in the deny-by-default route map
use_not_allowedREST-only key used at MCP, or MCP-only key used on REST
ip_not_allowedCaller 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:

codeWhendata
FORM_GATE_CREATECreating a job in a gated status{ "status" }
FORM_GATE_TRANSITIONMoving a job into a gated status with open form counters{ "gate": "start" | "completion", "outstanding" }
FORM_GATE_DELETEDeleting a job (or cascading a customer) with open form countersCustomer cascade: { "ticketIds": […] }. Job delete: no data
FORM_GATE_INVOICECreating/updating an invoice or converting a quote for a job with openInvoiceFormCount > 0{ "outstanding" }
CUSTOMER_HAS_TICKETSDELETE customer with ?cascade=false while jobs exist{ "ticketIds": […] }
LAST_STATUSDeleting the organisation's last job status—
STATUS_IN_USEDeleting a job status still used by a ticket—
STATION_IN_USEDeleting a station assigned to an active (non-closed) job—
CALENDAR_EVENT_EXISTSSecond calendar event for the same job—
PAYMENT_ACCOUNTING_MANAGEDMutating a Stripe- or accounting-linked payment—
QUOTE_ALREADY_INVOICEDPOST …/quotes/{id}/convert when already converted (pass ?force=true)—
RECURRING_VISITRescheduling a generated series visit via the calendar—
SERIES_SHAPE_CHANGERecurring-schedule edit that changes series shape without ?resync=thisAndFuture—
OCCURRENCE_WORKEDSkipping a visit that already has work on the job—
OCCURRENCE_NOT_GENERATEDMove/resize of a visit that has no job yet—
SCHEDULE_IN_USEDeleting 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: 12

Failed-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

StatusMeaningRetry?
500Unexpected error. Body includes requestId (Vercel x-vercel-id, else a UUID)Yes, with backoff. Quote requestId to support
502Portal could not reach the OAuth WorkerYes, briefly
503Production 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
504Portal → Worker timed outYes, 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 429 after Retry-After (seconds) or X-RateLimit-Reset
  • Retry 500 / 502 / 503 / 504 with 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"]