Sync completed jobs to another system
When an engineer completes a job, the office usually needs a record of it somewhere else: an invoice to raise, a CRM timeline to update, a row in your own reporting. This recipe takes the job.completed webhook, fetches the job with its customer, site and parts, and creates one record in the other system, however many times the event arrives.
It’s written against a generic target system. Swap createRecordInTarget for the call your accounting package, CRM or database takes.
Before you start
Section titled “Before you start”A receiver for the webhook. Receive a completed-job webhook builds one that checks signatures, stores each event once and processes it after answering. This recipe is the handleCompletedJob step it leaves for you.
An API key in the right brand. Create it in Settings > API Keys. A key belongs to one brand and sees only that brand’s records, while a webhook endpoint receives every brand’s events. Make the key in the brand whose work you’re syncing, and check the event’s brand_id before you fetch anything. A job from another brand answers 404 to this key.
Tick these permissions:
| Permission | Why |
|---|---|
read:jobs |
Fetch the job |
read:customers |
Include the customer |
read:locations |
Include the site |
read:job_templates |
Include the job type’s name |
read:job_parts |
List the parts used |
read:parts_catalog |
Name those parts |
update:jobs |
Write your reference back onto the job |
Add read:custom_field_values only if you need the job’s custom fields.
Fetch the job
Section titled “Fetch the job”The webhook body carries ids, not details, and describes the job as it was when the event was built. Fetch the job itself, and use select to bring in its customer, site and job type in the same call:
curl "https://api.workwing.io/v1/jobs/0198f0c4-5a11-7c62-9d84-000000000003?select=id,job_number,title,status,kind,completed_at,external_references,customer:customers(id,name,email),location:locations(formatted_address,postal_code),template:job_templates(name)" \ -H "Authorization: Bearer ww_sk_live_abc123.xxxxxxxx"{ "data": { "id": "0198f0c4-5a11-7c62-9d84-000000000003", "job_number": "JOB-00042", "title": null, "status": "T_COMPLETED", "kind": "T_STANDARD", "completed_at": "2026-09-09T10:38:00.000Z", "external_references": [], "customer": { "id": "0198f0c4-5a11-7c62-9d84-000000000005", "name": "Acme Building Services", "email": "accounts@acme.example" }, "location": { "formatted_address": "1 Station Road, Reading RG1 1AA, UK", "postal_code": "RG1 1AA" }, "template": { "name": "Boiler service" } }, "meta": { "request_id": "0198f0c4-9b12-7d5a-8e41-6c2b7a9f0d33", "timestamp": "2026-09-09T11:04:20.114Z" }}A related record your key isn’t permitted to read comes back as null rather than as an error. If customer is null on a job that has one, the key is missing read:customers.
Check status before you go on. The event told you the job was completed, but by the time you process it someone may have reopened it.
Fetch the parts
Section titled “Fetch the parts”curl https://api.workwing.io/v1/jobs/0198f0c4-5a11-7c62-9d84-000000000003/parts \ -H "Authorization: Bearer ww_sk_live_abc123.xxxxxxxx"data is an array, newest first. Each part has part_name, part_sku, part_unit_label, quantity and unit_cost_at_use, the cost recorded when the part was added to the job. quantity and unit_cost_at_use are decimals and arrive as strings, so convert them before doing arithmetic. Without read:parts_catalog, part_name and part_sku come back empty.
Create the record once
Section titled “Create the record once”The webhook recipe already stops one event being handled twice. That isn’t the same as one record per job: a job can be reopened and completed again, and the second completion is a new event with a new id. So key your own record on the WorkWing job id, and check it before you create anything.
const API = "https://api.workwing.io/v1";const headers = { Authorization: `Bearer ${process.env.WORKWING_API_KEY}` };
const JOB_SELECT = [ "id,job_number,title,status,kind,completed_at,external_references", "customer:customers(id,name,email)", "location:locations(formatted_address,postal_code)", "template:job_templates(name)",].join(",");
export async function handleCompletedJob(eventJob) { if (eventJob.kind !== "T_STANDARD") return;
const synced = await sql` SELECT 1 FROM synced_jobs WHERE workwing_job_id = ${eventJob.id} `; if (synced.length > 0) return;
const jobRes = await fetch(`${API}/jobs/${eventJob.id}?select=${JOB_SELECT}`, { headers }); if (jobRes.status === 404) return; if (!jobRes.ok) throw new Error(`WorkWing answered ${jobRes.status}`); const { data: job } = await jobRes.json();
if (job.status !== "T_COMPLETED") return;
const partsRes = await fetch(`${API}/jobs/${job.id}/parts`, { headers }); if (!partsRes.ok) throw new Error(`WorkWing answered ${partsRes.status}`); const { data: parts } = await partsRes.json();
const record = await createRecordInTarget({ sourceId: job.id, reference: `WorkWing ${job.job_number}`, customer: job.customer, site: job.location?.formatted_address ?? null, work: job.template?.name ?? job.title, completedAt: job.completed_at, lines: parts.map((p) => ({ description: p.part_name, code: p.part_sku, quantity: Number(p.quantity), unitCost: p.unit_cost_at_use === null ? null : Number(p.unit_cost_at_use), })), });
await sql` INSERT INTO synced_jobs (workwing_job_id, target_ref) VALUES (${job.id}, ${record.id}) `;
await linkBack(job.id, record);}The first line skips projects, so a project’s sign-off doesn’t produce a record of its own on top of one for each of its jobs. Drop it if a project is the thing you want to sync.
A 404 means the job is in a brand this key can’t see, or was deleted after the event was sent. Either way there’s nothing to sync, so the event is finished rather than retried. Any other failure throws, and the event is tried again on the next pass.
There’s still a gap: if the target system accepts the record but your process stops before the synced_jobs row is written, the next pass creates it again. Close it on the target side wherever you can. Most accounting and CRM APIs take an idempotency key or an external reference of their own: pass the WorkWing job id there, so a second attempt finds the first record instead of creating another.
Link the record back to the job
Section titled “Link the record back to the job”Write the other system’s reference onto the job, so the office can see it was synced and click through to it:
async function linkBack(jobId, record) { const res = await fetch(`${API}/jobs/${jobId}/external-references`, { method: "POST", headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": `link-${jobId}-${record.id}`, }, body: JSON.stringify({ provider: "acme-ledger", type: "invoice", ref: record.number, url: record.url, }), }); if (!res.ok) throw new Error(`WorkWing answered ${res.status}`);}The reference shows on the job’s page under Linked Systems. A job holds one reference for each pair of provider and type, so sending the same pair again replaces it rather than adding a second. The url must be HTTPS, and a job can hold 20 references.
References come back in the job’s external_references, which is why the fetch above selects it. If more than one process writes to the target, or you rebuild your own database, check there for your provider before creating a record:
const alreadyLinked = job.external_references.some( (r) => r.provider === "acme-ledger" && r.type === "invoice",);Catch up on anything you missed
Section titled “Catch up on anything you missed”Events raised while an endpoint is switched off are not kept, and a receiver can be down for longer than WorkWing keeps trying. Run a catch-up from time to time that lists recently completed jobs and puts each one through the same handleCompletedJob. The synced_jobs check makes it safe to run as often as you like.
curl "https://api.workwing.io/v1/jobs?status=eq.T_COMPLETED&kind=T_STANDARD&completed_at=gte.2026-09-08T00:00:00Z&order=completed_at.asc&limit=100&offset=0&select=id,kind" \ -H "Authorization: Bearer ww_sk_live_abc123.xxxxxxxx"Page through with offset until a page comes back with fewer rows than limit, and start the next run from a little before the last completed_at you saw. Each page is a request against your key’s per-minute limit, so read X-RateLimit-Remaining and wait out Retry-After on a 429 (see Rate limits).
What’s next
Section titled “What’s next”- Receive a completed-job webhook: the receiver this recipe plugs into
- Getting started with the API: filters, paging, errors and permissions
- API reference: every endpoint and field