Handling errors
Most API errors fall into two kinds. Some say the request itself is wrong, and sending it again gets the same answer. Others say “not now”, and the same request will work if you wait. This recipe sorts the codes into those two kinds and ends with a small helper that retries only the second.
Every error has the shape described in Getting started with the API: a stable error.code to branch on, a message for your logs, sometimes details, and a request_id under meta. Branch on the code, log the rest, and quote the request_id if you contact us.
The request is wrong
Section titled “The request is wrong”These won’t change on a retry. Fix the request, or tell a person.
Validation
Section titled “Validation”| Code | Status | Meaning |
|---|---|---|
E_AP_6002 |
400 | A value in the body or the address is wrong: a malformed id, a field the endpoint doesn’t accept, an end before a start. details lists the offending fields |
E_AP_6001 |
400 | A required field is missing, including a required field on the job type when you create a job |
E_AP_6003 |
400 | A filter, select or order names a column that doesn’t exist, or can’t be parsed |
E_AP_6004 |
416 | The paging range can’t be satisfied |
E_AP_6011 |
400 | A write was sent without an Idempotency-Key |
E_AP_6012 |
400 | The Idempotency-Key must be 1-256 printable ASCII characters. It is empty, too long, or contains other characters |
E_DB_6009 |
400 | A customer phone number can’t be read as a full international number. Send it with its country code, such as +44 |
details on a validation error is a list with an entry per offending field. Its exact shape varies between endpoints, so log it whole rather than parsing it.
Creating or updating a customer, location, resource or job refuses a field the endpoint doesn’t model rather than ignoring it. If one of those fails with E_AP_6002 and the body looks right, check every key against the endpoint in the API reference.
Some rules are enforced by the database itself rather than the API, and a write that breaks one answers with that rule’s own E_DB_* code, usually with status 400. E_DB_6009 above is the one you’re most likely to meet. Treat any E_DB_* code like the validation codes in this table: the request is wrong, and sending it again gets the same answer.
Authentication and permissions
Section titled “Authentication and permissions”| Code | Status | Meaning |
|---|---|---|
E_AP_1001 |
401 | No key was sent |
E_AP_1002 |
401 | The key isn’t valid |
E_AP_1003, E_AP_1005, E_AP_1006 |
401 | The key has been revoked, is inactive, or has expired |
E_AP_1007 |
401 | The key can’t be used. Create a new one |
E_AP_1102 |
403 | The key lacks the permission this call needs, or isn’t allowed to touch that record |
E_AP_1101 |
403 | The key holds the permission but can’t write that record. Writes to customers, locations and resources, and updates and deletes of jobs, report this case as E_AP_1101; other endpoints use E_AP_1102 |
E_AP_1103 |
403 | The request came from an IP address the key doesn’t allow |
A 401 needs a new key and a 403 needs a key with different permissions, so stop and alert someone rather than retrying.
Your plan and billing
Section titled “Your plan and billing”| Code | Status | Meaning |
|---|---|---|
E_AP_4201 |
402 | The job allowance for this billing period is used up |
E_AP_4202 |
402 | The overage spend cap for this billing period is reached |
E_AP_4203 |
403 | Billing access is limited, so the API is read-only until the balance is settled |
E_AP_4204 |
403 | Your plan doesn’t include API access |
These clear when someone changes the plan or the billing, not on a timer. Park the work and tell a person.
The record and its state
Section titled “The record and its state”| Code | Status | Meaning |
|---|---|---|
E_AP_4001 |
404 | Not found, or your key can’t see it |
E_AP_4004 |
400 or 409 | An id in the body points at something that doesn’t exist, such as an unknown customer or job type. Writes to customers, locations and resources, and updates and deletes of jobs, answer 409 |
E_AP_4002, E_AP_4003 |
409 | Conflicts with what’s already there, such as an engineer already on the job |
E_AP_4005, E_AP_4006 |
409 | From the assign-next-available workflow: the job already has someone, or nobody is free |
E_AP_4011 |
409 | That status change isn’t allowed from the job’s current status, or you tried to set status with a plain update |
E_DB_1111 |
403 | The job’s schedule is locked. A key can lock a job, but moving or unlocking a locked one has to be done in the app by someone allowed to reschedule locked jobs |
E_AP_4012, E_AP_4013 |
409 | The person can’t be assigned: they can no longer sign in, or their access has ended |
E_AP_4007 |
422 | A job already holds the maximum number of external references |
E_AP_4014 |
410 | The endpoint has been retired. The message names the one to use |
E_AP_6010 |
422 | An Idempotency-Key was reused with a different request |
E_AP_6013 |
409 | The write with this Idempotency-Key was applied, but its original response is no longer available. Don’t retry: fetch the resource to see its current state |
Some of these are good news in disguise. E_AP_4002 on an assignment you were retrying means the engineer is already on the job, which is what you wanted.
The request is fine, the timing isn’t
Section titled “The request is fine, the timing isn’t”These are worth retrying with the same request and the same Idempotency-Key.
| Code | Status | Meaning | Wait |
|---|---|---|---|
E_AP_8001 |
429 | You’re over your rate limit | The Retry-After header, in seconds. If it’s missing, back off instead |
E_AP_8003 |
409 | An earlier request with this Idempotency-Key is still running |
The Retry-After header (1 second), then send it again |
E_AP_9001 |
500 | Something went wrong on our side | Back off, and stop after a fixed number of attempts (the helper below stops at five) |
E_AP_9002 |
503 | A service the API depends on is briefly unavailable | Back off |
Treat a dropped connection, a timeout, and any 5xx whose body has no error.code the same way: the request may or may not have reached WorkWing, and only a retry with the same idempotency key finds out safely. Create jobs safely with idempotency explains why that’s safe for writes.
Reads (GET) are always safe to retry. Writes are safe to retry only with the key the first attempt used.
A retry helper
Section titled “A retry helper”This wraps fetch for every call. It sends one idempotency key for all the attempts at a write, retries only the codes in the second table, honours Retry-After, and otherwise backs off exponentially with some jitter.
import { randomUUID } from "node:crypto";
const BASE = "https://api.workwing.io/v1";
export class WorkWingError extends Error { constructor(status, error, requestId) { super(`${error?.code ?? status}: ${error?.message ?? "No error body"}`); this.status = status; this.code = error?.code; this.details = error?.details; this.requestId = requestId; }}
function isRetryable(status, code) { if (status === 429) return true; if (code === "E_AP_8003") return true; return status >= 500;}
function waitMs(res, attempt) { const retryAfter = Number(res?.headers.get("Retry-After")); if (retryAfter > 0) return retryAfter * 1000; const backoff = Math.min(30_000, 500 * 2 ** attempt); return backoff / 2 + Math.random() * (backoff / 2);}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function workwing( method, path, { body, idempotencyKey, maxAttempts = 5, headers = {} } = {},) { const key = method === "GET" ? undefined : (idempotencyKey ?? randomUUID()); const payloadText = body === undefined ? undefined : JSON.stringify(body);
for (let attempt = 1; ; attempt++) { let res; try { res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${process.env.WORKWING_API_KEY}`, "Content-Type": "application/json", ...(key && { "Idempotency-Key": key }), ...headers, }, body: payloadText, }); } catch (networkError) { if (attempt >= maxAttempts) throw networkError; await sleep(waitMs(null, attempt)); continue; }
const text = await res.text(); const payload = text ? JSON.parse(text) : null;
if (res.ok) { return { data: payload?.data, headers: res.headers, status: res.status }; }
const error = payload?.error; if (!isRetryable(res.status, error?.code) || attempt >= maxAttempts) { throw new WorkWingError(res.status, error, payload?.meta?.request_id); } await sleep(waitMs(res, attempt)); }}Using it:
try { const { data: job } = await workwing("POST", "/jobs", { body: { customer_id, template_id, title: "Annual boiler service" }, idempotencyKey: `shop-order-${orderId}-create-job`, });} catch (err) { if (err instanceof WorkWingError && err.code === "E_AP_4004") { // The customer or job type id is wrong: flag the order for a person. } throw err;}Two details in the helper matter. The body is serialised once, before the loop, so every attempt sends identical JSON; a body rebuilt on each attempt can come out different and be refused with E_AP_6010. And the key is chosen before the loop too, so a retry is the same operation rather than a new one.
If the caller passes its own idempotencyKey, built from your own record, a retry still matches after your process restarts. Leave it out and the helper makes a random one that lasts as long as this call.
Staying under the rate limit
Section titled “Staying under the rate limit”Retrying a 429 works, but it’s cheaper not to hit one. Every response carries X-RateLimit-Remaining and X-RateLimit-Reset (see Rate limits). A batch job can read them from headers and slow down as X-RateLimit-Remaining nears zero, rather than running into the wall and waiting out Retry-After. Nightly customer sync shows this in a loop.
What’s next
Section titled “What’s next”- Create jobs safely with idempotency: the replay, conflict and in-progress answers in detail
- API reference: every endpoint, with the errors it lists