Skip to content

Getting started with the API

The REST API lets your own software read and write the same jobs, customers, engineers and schedules your team works with in WorkWing. It’s on every plan. What changes with the plan is how many keys you can hold and how many requests a minute you can make.

Go to Settings > API Keys and click Create API Key. API keys covers the form in full, but two choices matter before you write any code.

The key belongs to one brand. Everything it reads and writes is that brand’s.

Permissions. Tick only what the integration needs. A nightly export that reads jobs should hold nothing that can write one.

The key is shown once, when you create it. Copy it straight into wherever it’s going to live. If you lose it, revoke it and make another.

Every request goes to the versioned base address and carries the key as a bearer token.

Terminal window
curl https://api.workwing.io/v1/jobs?limit=5 \
-H "Authorization: Bearer ww_sk_live_abc123.xxxxxxxx"

Your organisation and brand come from the key, not from the address you call, so the base address is the same for everyone.

The full endpoint list, with every request body and response shape and a console you can call them from, is in the API reference.

If an AI coding assistant (Claude, ChatGPT, Cursor, Copilot and the like) is writing the integration, give it the machine-readable description of the API rather than the reference page, which is built for a browser:

  • https://docs.workwing.io/openapi.json is the OpenAPI 3 document behind the reference: every endpoint, body, response, error and required permission.
  • https://docs.workwing.io/llms.txt is a short index of these docs written for language models.

A prompt along these lines is enough to start from:

I'm integrating with the WorkWing field service API. Read the OpenAPI spec at
https://docs.workwing.io/openapi.json and the guide at
https://docs.workwing.io/api/getting-started/ before writing any code. Every
write needs an Idempotency-Key header, and responses wrap results in "data".

This is for writing code against the API. To let an assistant work in WorkWing directly, booking jobs and looking up schedules, connect it over MCP instead: see AI assistants.

A permission is written as an action and a resource, joined by a colon: read:jobs, create:customers, delete:locations. The API keys form shows a friendly label next to each and you can tick a whole group at once, but these strings are what the key actually carries and what you’ll see listed against it.

Resource Actions available
jobs read, create, update, delete
customers read, create, update, delete
resources read, create, update, delete
locations read, create, update, delete
job_schedules read, create, update
job_parts read, create, delete
custom_field_values read, create, update, delete
custom_field_definitions read, create, update
Reference data read:job_templates, read:parts_catalog, read:brands, read:skills, read:tenant
integrations read, execute

Notes and attachments on a job ride on the job permissions, so read:jobs gets you a job’s notes without a separate tick. Projects do too, because a project is a group of jobs. Parts consumed on a job are the exception and have their own.

Creating a job means naming a customer, and usually a site and a job type, so create:jobs also lets the key read those: a key ticked for reading and creating jobs can create one without any customer or location permission. It reads them, it cannot change them, and it sees only the brand the key belongs to. If that is more than you want a key to see, mint a separate one for reading jobs.

Putting someone on a job needs update:jobs as well as the permission for the call itself. That covers POST /v1/jobs/{id}/assignments, and assigned_resource_id on POST /v1/jobs: a key with create:jobs alone can create a job but not assign it.

A request without the permission it needs comes back as 403 with the code E_AP_1102. On most routes the same answer covers a record your key is not allowed to touch, such as one belonging to a different organisation, because permissions are enforced on the row itself. The message says the key is not permitted to perform the operation on the record rather than naming a scope, since the two are not distinguishable at that point. The list and one-record routes for jobs, customers, resources and locations (apart from POST /v1/jobs), and the reference data, answer a refused record with 403 E_AP_1101 instead. If you get either on a call you expect to work, check the record as well as the ticks on the key.

Every POST, PATCH, PUT and DELETE must carry an Idempotency-Key header. Send a value you generate per operation, such as a UUID, 1 to 256 printable ASCII characters.

Terminal window
curl -X POST https://api.workwing.io/v1/customers \
-H "Authorization: Bearer ww_sk_live_abc123.xxxxxxxx" \
-H "Idempotency-Key: 7d6f1c2a-4b3e-4d5f-9a10-2c8e5b1f3a44" \
-H "Content-Type: application/json" \
-d '{"name": "Acme Building Services"}'

This is what makes a retry safe. A write sent with a given key is applied at most once within 24 hours: if you never received a response, send the same key with the same request again and you get the original response back rather than a second customer. A key is consumed only when the write is applied, so a request that was refused or failed leaves it free to use again. Send the same key with a different request and it is refused with E_AP_6010, which is the API telling you two different operations are sharing a key.

Omit the header and the write is rejected with E_AP_6011; an empty, over-long or non-ASCII key is rejected with E_AP_6012. Every write in the API reference lists the header on the operation, so a client generated from the spec sends it. The one exception is /v1/integrations, which does not replay: the reference leaves the header off those two operations because sending it changes nothing.

Every response carries your current position:

Header What it holds
X-RateLimit-Limit Requests allowed per minute
X-RateLimit-Remaining How many are left in the current window
X-RateLimit-Reset When the window resets

The limit is per key, per minute, and your plan sets it: 60 on Free, 120 on Basic, 600 on Standard and 2,000 on Professional. A second key gets its own allowance rather than a share of the first one’s.

Go over it and you get 429 with the code E_AP_8001 and a Retry-After header saying how many seconds to wait. Wait that long and try again rather than retrying immediately.

Every successful response has the same shape. Your result is under data, and meta carries the request id you would quote if you need to ask about a specific call.

{
"data": [
{ "id": "0198f0c4-9b12-7d5a-8e41-6c2b7a9f0d33", "name": "Acme Building Services" }
],
"meta": {
"request_id": "0198f0c4-9b12-7d5a-8e41-6c2b7a9f0d33",
"timestamp": "2026-09-09T10:15:00.000Z"
}
}

data holds whatever you asked for: an array for a list, a single object for the endpoints that return one. A write returns what it wrote, so a delete comes back as 200 carrying the rows it removed rather than an empty 204. Paging totals stay on the Content-Range header rather than in meta.

The one case with no envelope is the one with no body. Send Prefer: return=minimal or Prefer: return=headers-only on a write and you get 201 and nothing to unwrap, which is the point of asking for them. return=headers-only still tells you what it made: the new row’s path comes back in the Location header, so you can create something and fetch it later without paying for the body now.

Every error has the same shape. The code is stable and worth branching on; the message is for a human reading a log.

{
"error": {
"code": "E_AP_1102",
"message": "Insufficient scope",
"details": {}
},
"meta": {
"request_id": "0198f0c4-9b12-7d5a-8e41-6c2b7a9f0d33",
"timestamp": "2026-09-09T10:15:00.000Z"
}
}

The codes are grouped by what went wrong: 10xx is authentication, 11xx is permissions, 40xx is the resource you asked for, 42xx is your plan or your billing, 60xx is the request itself, 70xx is an integration you ran, 80xx is rate limiting and 90xx is us. Quote the request_id if you get in touch about a specific call.

The HTTP status follows the group: 10xx arrives as 401, 11xx as 403, 40xx as 404, 42xx as 402, 60xx as 400, 70xx as 503, 80xx as 429 and 90xx as 500. These codes are the exceptions:

Status Codes
400 E_AP_4004
403 E_AP_4203, E_AP_4204
408 E_AP_7002
409 E_AP_4002, E_AP_4003, E_AP_4005, E_AP_4006, E_AP_4011, E_AP_4012, E_AP_4013, E_AP_7005, E_AP_8003
410 E_AP_4014, E_AP_7007
413 E_AP_6002 when an attachment is too large
416 E_AP_6004
422 E_AP_4007, E_AP_6010, E_AP_7003

A 409 means the record or the request is in a state that refused you. On the list and one-record routes for customers, resources and locations, and on PATCH and DELETE for jobs, E_AP_4004 arrives as 409 rather than 400. The code stays the same whatever the status. POST /v1/workflows/assign-next-available-engineer answers 409 E_AP_4005 when the job already has an engineer on it and 409 E_AP_4006 when nobody on the roster is free for its window; 404 on that endpoint means the job itself was not found. Branch on the code rather than the status: the code is the part that will not move.

List endpoints take filter, sort and paging parameters in the query string.

GET /v1/jobs?status=eq.T_PENDING
GET /v1/jobs?limit=25&offset=50
GET /v1/jobs?order=created_at.desc

A filter is the column, then the operator and value joined by a dot. Combine them freely, and always page rather than pulling a whole table. A page holds at most 1,000 rows.

To know how many rows there are in total, send Prefer: count=exact. The total comes back in the Content-Range header, after the slash: 0-24/312 is the first 25 of 312.

Jobs, customers, resources and locations can each be fetched, updated and deleted by id:

GET /v1/jobs/{jobId}
PATCH /v1/jobs/{jobId}
DELETE /v1/jobs/{jobId}

data is then a single object rather than an array, and an id that does not exist, or that your key cannot see, answers 404 with E_AP_4001. Add ?select= to a GET to choose columns or pull in related records, exactly as on a list.

PATCH and DELETE on the collection itself, such as PATCH /v1/jobs?customer_id=eq.{customerId}, change every row matching the filter. Reach for those only when you mean to change many rows at once.

A job’s status is the one field you can’t set with an ordinary update, because moving a job between statuses has rules and a history behind it. A PATCH to a job carrying status is refused with E_AP_4011 and a message pointing you at the right endpoint.

Use POST /v1/jobs/{id}/status to move a job, and POST /v1/jobs/{id}/assignments to put an engineer on one.

To show customers where their job is up to on your own site, call GET /v1/jobs/{id}/tracking with a key holding read:jobs. It returns what the customer portal shows:

  • stage: where the job is up to, in the steps a customer understands: received, scheduled, on the way, in progress, complete, or cancelled. The API reference lists the values
  • scheduled_window_start and scheduled_window_end: the booked window
  • service_summary: the job type
  • technician_first_name and eta_at: while someone is on the way
  • position: a rough location for the engineer while they are on the way, or null

The position is approximate and slightly behind on purpose, so it is safe to put in front of a customer. Your portal settings do not apply here: you decide what your page shows.

Never build a customer-facing page from the job’s location history, the resource’s current_latitude and current_longitude or the resource.location_updated webhook. Those carry your engineer’s live position and are for your own team only.

Show customers your own reference for the job, such as the job number, and keep the WorkWing job id out of the page and its links.