Skip to content

Create and schedule a job

This recipe books a job from your own software the way a dispatcher would in WorkWing: find the customer (or add them), pick the site, create the job with a job type and a time window, then put someone on it. Each step names the ids it needs from the step before.

The examples use Node 18 or later, where fetch and crypto.randomUUID() are built in. They assume the setup in Getting started with the API.

Tick these permissions on the key in Settings > API Keys:

Step Permissions
Find or create the customer read:customers, create:customers
Find or add the site read:locations, create:locations
Create the job create:jobs, read:jobs
Put an engineer on it update:jobs, plus read:resources to list your engineers

Assigning is an update to the job, so a key that can create jobs but not update them gets a 403 E_AP_1102 on the last step. That includes naming an engineer in the create call itself.

Every write needs its own Idempotency-Key. This helper sends one and unwraps data. Handling errors has a version that also retries safely.

import { randomUUID } from "node:crypto";
const BASE = "https://api.workwing.io/v1";
async function workwing(method, path, body, idempotencyKey = randomUUID()) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.WORKWING_API_KEY}`,
"Content-Type": "application/json",
...(method !== "GET" && { "Idempotency-Key": idempotencyKey }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (!res.ok) {
const err = new Error(`${payload.error.code}: ${payload.error.message}`);
err.status = res.status;
err.code = payload.error.code;
err.details = payload.error.details;
throw err;
}
return payload.data;
}

Look the customer up by something your system already holds. If you keep your own customer number, store it in external_crm_id (with external_crm_type naming your system) and search on that. Otherwise search by email.

const params = new URLSearchParams({
external_crm_id: "eq.CUST-10442",
select: "id,name,primary_location_id",
});
let [customer] = await workwing("GET", `/customers?${params}`);
if (!customer) {
[customer] = await workwing("POST", "/customers", {
customer_type: "T_ORGANIZATION",
name: "Acme Building Services",
email: "accounts@acme.example",
phone: "+442079460000",
external_crm_id: "CUST-10442",
external_crm_type: "my-crm",
});
}

Both calls answer with an array: the search because it’s a list, and POST /v1/customers because it returns the rows it created. Destructuring takes the first one. The customer is created in the key’s brand.

WorkWing doesn’t stop two customers from sharing an external_crm_id, so if a search ever returns more than one, sort that out by hand rather than picking one at random.

A job happens at one of its customer’s sites, which the API calls locations. List the customer’s sites and choose one, or add the site if it isn’t there yet:

const sites = await workwing(
"GET",
`/locations?customer_id=eq.${customer.id}&postal_code=eq.LS1%206DT&select=id,formatted_address`,
);
let [site] = sites;
if (!site) {
[site] = await workwing("POST", "/locations", {
customer_id: customer.id,
address_line1: "14 Market Street",
locality: "Leeds",
postal_code: "LS1 6DT",
country: "GB",
});
}

A site you add over the API isn’t made the customer’s primary site. To make it the one new jobs fall back to, call POST /v1/locations/{locationId}/set-primary, which needs update:customers.

You can leave location_id off the job altogether. WorkWing then uses the customer’s primary site, and if the customer has none, the job has no site.

What WorkWing calls a job type is a job template in the API: the endpoint is /v1/job_templates and the field on a job is template_id. List the active ones and keep the id of the one you want:

const jobTypes = await workwing(
"GET",
"/job_templates?is_active=is.true&select=id,name,estimated_duration_minutes&order=name.asc",
);
const boilerService = jobTypes.find((t) => t.name === "Boiler service");

Job types rarely change, so look them up once and cache the ids rather than fetching them before every job.

const job = await workwing("POST", "/jobs", {
customer_id: customer.id,
location_id: site.id,
template_id: boilerService.id,
title: "Annual boiler service",
scheduled_start: "2026-10-06T09:00:00Z",
scheduled_end: "2026-10-06T11:00:00Z",
notes: "Side gate code 4471",
});
console.log(job.id, job.job_number);

customer_id and template_id are the two ids you must send. Everything else is optional: leave the window off and the job waits unscheduled until someone books it.

Send times in UTC, written with a trailing Z. The window can’t end before it starts, and it spans 14 days at most. To move it later, PATCH /v1/jobs/{jobId} with a new scheduled_start and scheduled_end.

If the job type has required fields of its own, send them in operator_form_data, keyed by the field id from the job type. A missing one is refused with E_AP_6001, and the message lists which.

Show your users the job_number (it’s the reference the rest of WorkWing uses) and keep the id for API calls.

There are three ways to do this, depending on who decides.

You know who. List your engineers once with GET /v1/resources?select=id,display_name, then assign:

const assignment = await workwing("POST", `/jobs/${job.id}/assignments`, {
resource_id: "0199a1b2-c3d4-7000-8000-00000000e001",
});

You know who, at creation time. Add assigned_resource_id to the body in step 4 instead. The job and its assignment are created together or not at all, so a refused assignment never leaves an unassigned job behind for you to clean up.

Let WorkWing pick. Ask for the next free engineer:

const picked = await workwing("POST", "/workflows/assign-next-available-engineer", {
job_id: job.id,
});
console.log(picked.assigned_engineer_name);

It picks someone on the roster with nothing else booked in the job’s window, preferring whoever has the lightest load. Skills and travel aren’t considered. If you need either, choose the engineer yourself.

Branch on error.code. These are the ones this sequence produces:

Code Status Where What to do
E_AP_6002 400 Any write The body is wrong. details names the field. Fix it and send it with a new idempotency key
E_AP_6001 400 Create job A required job type field is missing. The message lists them
E_AP_4004 400 Create job, assign An id the key can’t find: the customer, the job type (or it’s inactive), a site that belongs to a different customer, or the engineer
E_AP_1102, E_AP_1101 403 Any The key is missing a permission, or can’t touch that record
E_AP_4001 404 Assign The job wasn’t found
E_AP_4002 409 Assign That engineer is already on this job. Treat it as done
E_AP_4005 409 Assign next available The job already has someone on it
E_AP_4006 409 Assign next available Nobody on the roster is free for the window. Pick someone yourself or move the window
E_AP_4012, E_AP_4013 409 Assign That person can no longer sign in, or their access here has ended
E_AP_4201, E_AP_4202 402 Create job, assign Your plan’s job allowance or your overage spend cap is used up for this billing period

None of these get better by retrying the same request. Rate limits and server errors do, and Handling errors covers telling the two apart.