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.
The key you need
Section titled “The key you need”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.
A small helper
Section titled “A small helper”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;}1. Find or create the customer
Section titled “1. Find or create the customer”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.
2. Pick the site
Section titled “2. Pick the site”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.
3. Choose the job type
Section titled “3. Choose the job type”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.
4. Create the job
Section titled “4. Create the 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.
5. Put an engineer on it
Section titled “5. Put an engineer on it”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.
When a step is refused
Section titled “When a step is refused”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.
What’s next
Section titled “What’s next”- Create jobs safely with idempotency: make step 4 safe to retry after a timeout
- Webhooks: hear back when the engineer starts and finishes the job