Skip to content

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.

These won’t change on a retry. Fix the request, or tell a person.

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.

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.

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.

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.

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.

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.

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.