Skip to content

Webhooks

A webhook tells your systems something happened in WorkWing. Point it at a URL you control, pick the events you care about, and WorkWing posts the details as they happen. What you do with them is up to you: update a CRM, post to a chat channel, kick off your own billing run.

Open Settings > Webhooks. You need the Tenant Admin role, or a custom role that includes managing webhooks.

Webhooks are on every plan, Free included. Your plan sets how many endpoints you can have: one on Free, two on Basic, ten on Standard and fifty on Professional. The count next to Create Webhook shows where you are.

  1. Click Create Webhook.
  2. Enter the Endpoint URL that will receive the requests. Use HTTPS in production.
  3. Give it a Name if you want one. It is optional and only there to help you tell endpoints apart.
  4. Tick the Event Types you want. Leave every box empty and the endpoint receives all of them.
  5. Open Custom Headers if your receiver needs one, such as an authorisation token of its own. A header’s value is either plain text or a secret from Settings > Secrets.
  6. Click Create Webhook.
Event Fires when
job.created A job is created
job.updated A job’s fields change
job.status_changed A job moves to a different status
job.completed A job is completed
job.cancelled A job is cancelled
job.failed A job is marked failed
job.overdue A scheduled job passes its end time still open
job.sla_response_breached The response target on a job is missed
job.sla_resolution_breached The resolution target on a job is missed
job.note_added A person adds a note to a job
job.attachment_added A photo or file is attached to a job
job.deleted A job is deleted outright, as opposed to cancelled
customer.created A customer record is added
customer.updated A customer’s details change
customer.deleted A customer record is deleted
assignment.created A resource is put on a job
assignment.en_route A resource sets off, whether or not a travel time could be worked out
assignment.status_changed An assignment moves to a different status
assignment.on_site The engineer arrives at the job
assignment.completed The engineer finishes their part of the job
resource.status_changed A resource’s availability changes
resource.location_updated A resource on the move reports a new position, at most every 5 minutes
resource.created A resource is added
resource.updated A resource’s record changes: the person behind it, display name, email or tags

A status change sends two requests: job.status_changed every time, plus job.completed, job.cancelled or job.failed when the new status is one of those. Subscribe to one or the other, not both, unless you want each change twice.

assignment.en_route fires whether or not you send customers an en-route text message, and whether or not an arrival time could be worked out.

An assignment status change also sends two requests: assignment.status_changed every time, plus assignment.on_site or assignment.completed when the new status is one of those. Setting off sends assignment.en_route and assignment.status_changed.

job.note_added fires for notes people write, not for the entries WorkWing adds itself when a status changes or someone is assigned. Neither it nor job.attachment_added carries the note text or the file: fetch those over the API with the ids in the body.

job.deleted and customer.deleted carry only the id, and the job number, because the record is gone and there is nothing left to fetch. Cancelling a job is not deleting it: that sends job.cancelled. A customer with jobs cannot be deleted, so customer.deleted only ever names one that had none.

To hear when someone is put on a job, subscribe to assignment.created.

resource.location_updated is a sample, not a trace. A phone reports its position continuously, so the event is held to one request per resource every 5 minutes however often the position moves. It only fires while the resource is available, so nothing arrives while someone is on a break or off duty, and nothing arrives for a resource that is not sharing location at all. Use it to keep a map roughly current, not to reconstruct a route. The full history is on the resource in the API.

The position in resource.location_updated is live. It is for your own team, such as a dispatch map, and must not be shown to customers. To show a customer where their engineer is, call GET /v1/jobs/{jobId}/tracking instead (see Building your own tracking page): it gives a rough location that is safe to share, alongside the job’s stage and arrival estimate.

Every request arrives in the same envelope: an id for this exact event, the event name, the version of this shape, when it happened, your organisation’s id, the brand it happened under, and the detail under data. A job.completed request:

{
"id": "0198f0c4-5a11-7c62-9d84-3e7b1f0c8a25",
"event": "job.completed",
"version": "2026-09-12",
"created_at": "2026-09-09T11:04:18.902Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": "0198f0c4-5a11-7c62-9d84-000000000002",
"data": {
"job": {
"id": "0198f0c4-5a11-7c62-9d84-000000000003",
"number": "JOB-00042",
"status": "T_COMPLETED",
"previous_status": "T_IN_PROGRESS",
"priority": "T_NORMAL",
"template_id": "0198f0c4-5a11-7c62-9d84-000000000004",
"customer_id": "0198f0c4-5a11-7c62-9d84-000000000005",
"location_id": "0198f0c4-5a11-7c62-9d84-000000000006",
"scheduled_start": "2026-09-09T09:00:00.000Z",
"scheduled_end": "2026-09-09T11:00:00.000Z",
"started_at": "2026-09-09T09:04:00.000Z",
"completed_at": "2026-09-09T10:38:00.000Z",
"verification_code": "amber-otter",
"kind": "T_STANDARD",
"parent_job_id": null
}
}
}

A webhook body carries ids and the few plain values you need to decide whether you care: a job’s number and status, an assignment’s status, a breach deadline. It carries no names, no email addresses, no phone numbers, no addresses and no notes. verification_code is the exception to that list and is there on purpose: it is the code your customer is asked to check when someone arrives, so an integration that sends your own confirmations needs it. It is null for any brand that does not issue codes, and it is set once when the job is created, so every later event for that job repeats the same value. When you need those, read the record from the API with the ids in the body. That is the same advice as the section on order and repeats below, for the same reason: the body says what happened, and the API says how the record stands.

job.overdue, job.sla_response_breached and job.sla_resolution_breached carry the same job plus a breach object saying what was missed:

{
"id": "0198f0c4-5a11-7c62-9d84-000000000010",
"event": "job.overdue",
"version": "2026-09-12",
"created_at": "2026-09-09T11:05:00.000Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": "0198f0c4-5a11-7c62-9d84-000000000002",
"data": {
"job": {
"id": "0198f0c4-5a11-7c62-9d84-000000000011",
"number": "JOB-00051",
"status": "T_IN_PROGRESS",
"previous_status": null,
"priority": "T_HIGH",
"template_id": "0198f0c4-5a11-7c62-9d84-000000000004",
"customer_id": "0198f0c4-5a11-7c62-9d84-000000000005",
"location_id": "0198f0c4-5a11-7c62-9d84-000000000006",
"scheduled_start": "2026-09-09T09:00:00.000Z",
"scheduled_end": "2026-09-09T11:00:00.000Z",
"started_at": "2026-09-09T09:10:00.000Z",
"completed_at": null,
"verification_code": null,
"kind": "T_STANDARD",
"parent_job_id": null
},
"breach": {
"kind": "scheduled_end",
"due_at": "2026-09-09T11:00:00.000Z",
"overdue_by_seconds": 300
}
}
}

customer.created and customer.updated carry the customer:

{
"id": "0198f0c4-5a11-7c62-9d84-000000000020",
"event": "customer.updated",
"version": "2026-09-12",
"created_at": "2026-09-09T11:20:00.000Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": "0198f0c4-5a11-7c62-9d84-000000000002",
"data": {
"customer": {
"id": "0198f0c4-5a11-7c62-9d84-000000000005",
"customer_type": "T_ORGANIZATION",
"external_crm_id": "CRM-9981",
"external_crm_type": "salesforce",
"primary_location_id": "0198f0c4-5a11-7c62-9d84-000000000006",
"created_at": "2026-01-14T10:00:00.000Z",
"updated_at": "2026-09-09T11:20:00.000Z"
}
}
}

assignment.created carries the assignment:

{
"id": "0198f0c4-5a11-7c62-9d84-000000000030",
"event": "assignment.created",
"version": "2026-09-12",
"created_at": "2026-09-09T08:50:00.000Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": "0198f0c4-5a11-7c62-9d84-000000000002",
"data": {
"assignment": {
"id": "0198f0c4-5a11-7c62-9d84-000000000031",
"status": "T_ASSIGNED",
"job_id": "0198f0c4-5a11-7c62-9d84-000000000003",
"resource_id": "0198f0c4-5a11-7c62-9d84-000000000032",
"created_at": "2026-09-09T08:50:00.000Z"
}
}
}

assignment.en_route fires as soon as an engineer sets off, whether or not WorkWing could work out an arrival time, and adds a travel object next to the assignment. Its eta_minutes, eta_at and distance_meters are all nullable, and are always null together. The event means the engineer has set off; the arrival time is an extra WorkWing supplies when it can work one out. It cannot when the job has no location, when the engineer’s position is unknown or stale, or when the routing provider refuses the request. Treat an all-null travel as “on the way, arrival time unknown” rather than as a malformed request, and do not read eta_minutes as a number without checking it first. A request that did carry an arrival time:

{
"id": "0198f0c4-5a11-7c62-9d84-000000000040",
"event": "assignment.en_route",
"version": "2026-09-12",
"created_at": "2026-09-09T08:58:00.000Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": "0198f0c4-5a11-7c62-9d84-000000000002",
"data": {
"assignment": {
"id": "0198f0c4-5a11-7c62-9d84-000000000031",
"status": "T_EN_ROUTE",
"job_id": "0198f0c4-5a11-7c62-9d84-000000000003",
"resource_id": "0198f0c4-5a11-7c62-9d84-000000000032",
"created_at": "2026-09-09T08:50:00.000Z"
},
"travel": {
"eta_minutes": 12,
"eta_at": "2026-09-09T09:10:00.000Z",
"distance_meters": 4200
}
}
}

job.note_added and job.attachment_added carry the job plus the id, author and time of what was added:

{
"id": "0198f0c4-5a11-7c62-9d84-000000000070",
"event": "job.note_added",
"version": "2026-09-12",
"created_at": "2026-09-09T10:41:00.000Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": "0198f0c4-5a11-7c62-9d84-000000000002",
"data": {
"job": {
"id": "0198f0c4-5a11-7c62-9d84-000000000003",
"number": "JOB-00042",
"status": "T_IN_PROGRESS",
"previous_status": null,
"priority": "T_NORMAL",
"template_id": "0198f0c4-5a11-7c62-9d84-000000000004",
"customer_id": "0198f0c4-5a11-7c62-9d84-000000000005",
"location_id": "0198f0c4-5a11-7c62-9d84-000000000006",
"scheduled_start": "2026-09-09T09:00:00.000Z",
"scheduled_end": "2026-09-09T11:00:00.000Z",
"started_at": "2026-09-09T09:04:00.000Z",
"completed_at": null,
"verification_code": "amber-otter",
"kind": "T_STANDARD",
"parent_job_id": null
},
"note": {
"id": "0198f0c4-5a11-7c62-9d84-000000000071",
"created_by_user_id": "0198f0c4-5a11-7c62-9d84-000000000051",
"created_at": "2026-09-09T10:41:00.000Z"
}
}
}

assignment.status_changed, assignment.on_site and assignment.completed carry the assignment with the status it left and who moved it. The named two fire alongside assignment.status_changed for that transition:

{
"id": "0198f0c4-5a11-7c62-9d84-000000000045",
"event": "assignment.on_site",
"version": "2026-09-12",
"created_at": "2026-09-09T09:12:00.000Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": "0198f0c4-5a11-7c62-9d84-000000000002",
"data": {
"assignment": {
"id": "0198f0c4-5a11-7c62-9d84-000000000031",
"status": "T_ON_SITE",
"previous_status": "T_EN_ROUTE",
"job_id": "0198f0c4-5a11-7c62-9d84-000000000003",
"resource_id": "0198f0c4-5a11-7c62-9d84-000000000032",
"created_at": "2026-09-09T08:50:00.000Z",
"changed_by_user_id": "0198f0c4-5a11-7c62-9d84-000000000051"
}
}
}

Resources work across every brand in your organisation, so every resource.* event carries brand_id: null. resource.created fires when a resource is added and resource.updated when its record changes: the person behind it, display name, email or tags. Status and position have their own events above; skills, brand access and working hours live in other records and do not fire it. resource.status_changed:

{
"id": "0198f0c4-5a11-7c62-9d84-000000000050",
"event": "resource.status_changed",
"version": "2026-09-12",
"created_at": "2026-09-09T12:30:00.000Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": null,
"data": {
"resource": {
"id": "0198f0c4-5a11-7c62-9d84-000000000032",
"status": "T_ON_BREAK",
"previous_status": "T_AVAILABLE",
"changed_by_user_id": "0198f0c4-5a11-7c62-9d84-000000000051"
}
}
}

resource.location_updated adds the position it reported:

{
"id": "0198f0c4-5a11-7c62-9d84-000000000060",
"event": "resource.location_updated",
"version": "2026-09-12",
"created_at": "2026-09-09T09:05:00.000Z",
"tenant_id": "0198f0c4-5a11-7c62-9d84-000000000001",
"brand_id": null,
"data": {
"resource": {
"id": "0198f0c4-5a11-7c62-9d84-000000000032",
"status": "T_AVAILABLE",
"previous_status": null,
"changed_by_user_id": null
},
"location": {
"latitude": 51.5079,
"longitude": -0.0877,
"accuracy_meters": 12,
"recorded_at": "2026-09-09T09:05:00.000Z"
}
}
}

Checking that a request came from WorkWing

Section titled “Checking that a request came from WorkWing”

Every request carries three headers:

X-Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<exact request body>">
X-Webhook-Event: <the event name, e.g. job.created>
X-Webhook-Idempotency-Key: <the same value as the body's id>

The body’s own version field tells you which shape you are reading. It only changes when the fields do, so pin your parsing to a version you have checked and watch for a new one rather than assuming the shape stays put.

Version 2026-09-12 added two fields to the job object on every job event: kind, which says whether the job is an ordinary job or a project, and parent_job_id, which names the project a job belongs to and is otherwise null. Projects have no events of their own: a project is created, progresses and is signed off through the job events above.

t is the time this attempt was sent, in Unix seconds. v1 is the HMAC-SHA256, keyed with your signing secret and written as lowercase hex, of t, a full stop and the raw body. Work out the same value, compare, and refuse a t more than five minutes away from your own clock. Because t is signed, it can’t be changed without breaking the signature, so a captured request sent again later is refused.

import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
function isFromWorkWing(rawBody, headerValue, signingSecret) {
if (typeof headerValue !== "string") return false;
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(headerValue);
if (!match) return false;
const [, timestamp, signature] = match;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const expected = createHmac("sha256", signingSecret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return timingSafeEqual(Buffer.from(signature, "hex"), expected);
}

The format check comes first because timingSafeEqual throws when the two values differ in length, which is what a missing or malformed header gives you.

Every attempt is signed when it is sent, so a retry, or a Resend days later, carries the original body with a current t. Check the window against t, never against the body’s created_at, and keep your server’s clock synced.

Sign the body exactly as it arrived. Parsing the JSON and re-encoding it changes the bytes and the signature will not match.

Check the signature before you act on anything in the request.

Receive a completed-job webhook puts the signature check, a fast answer and duplicate handling together in one working receiver.

Your endpoint must be reachable from the public internet. A URL that resolves to a private or internal address is refused and not retried, and the reason is recorded. If you are testing on your own machine, put a tunnelling service in front of it.

Answer within 10 seconds. Acknowledge first and do the slow work afterwards on your side, rather than while WorkWing waits. A slower answer counts as a failed delivery.

Anything other than a 2xx is a failure too, and WorkWing tries again with a growing gap between attempts before giving up on that delivery.

Events are not delivered in order, and nothing about the design tries to make them so. Two events raised a second apart can reach you in either sequence, a retry lands after whatever was sent while it was failing, and your own queue can reorder them again after that. So read each request as “something happened to this record”, never as “this is the record now”.

When what you do next depends on the current state, take the ids out of the payload and fetch the record from the API. The API tells you how the record stands. The webhook body only tells you how it looked when the event was built.

The same event can also arrive more than once. An endpoint that takes longer than ten seconds is treated as failed and retried, even though it received and processed the first request.

X-Webhook-Idempotency-Key is how you tell the two apart, and it carries the same value as the body’s id. Every attempt at one delivery carries the same key, and a different event carries a different one. Store the key with whatever you do in response, and check it before you act: if you have seen it, you have already handled that event. This is the header to reach for when acting twice would be expensive, such as raising an invoice off job.completed.

The key is opaque. Compare it for equality and do not parse it.

You can attach 10 headers of your own to a webhook’s requests, which is how most receivers take their API key or token. Header names take letters, numbers and hyphens.

Some names are reserved and are refused when you add them. Anything starting X-Webhook- is reserved, along with Content-Type and User-Agent, because WorkWing sets those itself and a header of yours must not be able to replace one of them. Authorization is reserved too, so a receiver that wants a bearer token has to read it from a header of another name, such as X-Api-Key. The rest of the reserved list is the hop-by-hop set a client does not set by hand: Host, Connection, Content-Length, Transfer-Encoding, Cookie, Set-Cookie, Proxy-Authorization and Proxy-Connection.

Each header’s value is one of two things. Plain text is stored on the endpoint as you typed it, which is right for a flag or an account name. Secret stores only the name of a secret from Settings > Secrets; the value is read and decrypted when the webhook fires and is never held on the endpoint. Use it for anything that is a credential.

Choosing a secret needs the Tenant Admin role, because a webhook URL is yours to set and attaching a credential to one is the same trust as holding the secret store. Plain text headers need only the usual webhook permission.

A secret can carry its own list of allowed destinations. When it does, a webhook whose URL is not on that list is refused rather than sent, so if deliveries to an endpoint start showing as Refused, check that the endpoint’s host is one of the destinations recorded on the secret. A secret with no destinations recorded is not pinned and is sent to whatever URL the endpoint uses. A secret that has been deleted refuses the delivery either way, naming the key it could not find.

Every attempt WorkWing makes to reach an endpoint is recorded, and Deliveries on the webhook’s row in Settings > Webhooks lists them, newest first: when it was tried, which event, which attempt this was of the eight, the outcome, the HTTP status your endpoint answered with, and how long it took. Open a row to see the first thousand characters of what your endpoint sent back, or the reason WorkWing gave up.

Four outcomes appear. Succeeded means your endpoint answered with a 2xx. Failed means anything else, including no answer within ten seconds, and WorkWing tries again with a growing gap until the attempts run out. Refused means the request was never made: the URL resolves to a private or internal address, or a header’s secret is missing or not allowed to reach that host. Skipped means the webhook was switched off when the event arrived.

Resend on a failed or refused attempt sends the same body again, carrying the same X-Webhook-Idempotency-Key. An endpoint that did process the original can drop the copy by that key; one that never saw it gets what it missed. The resend appears as a new attempt that names the one it repeats.

Attempts are kept for thirty days.

An endpoint that has stopped answering is switched off rather than retried for ever. Two things have to be true at once: five events in a row have used up all eight of their attempts, and the run of failures started at least twenty-four hours ago. A receiver that restarts for ten minutes fails a burst of events and recovers, and the count resets on the first success, so nothing is switched off for that. A receiver that is still refusing everything a day later is treated as gone.

A URL that resolves to a private or internal address counts straight away, because it is refused rather than retried and can never succeed.

Every Tenant Admin gets an email when it happens, naming the endpoint, how many events were lost and the last error the receiver gave. In Settings > Webhooks the endpoint’s status reads Disabled after failures, and its Deliveries page says the same at the top, which is how you tell it apart from one you switched off yourself.

Nothing is queued or replayed while an endpoint is off: events raised in that time are gone. Fix the receiver, then activate the endpoint again from its row. That clears the count, so it starts from scratch. Changing the URL clears the count too, on the grounds that a new address is a new receiver. The events you missed cannot be recovered, but any attempt still in the Deliveries list can be sent again one at a time.

Each row’s actions menu covers the rest.

  • Edit changes the URL, the name, the events and the headers.
  • Rotate Secret issues a new signing secret and shows it once. The old one stops working straight away, so update your receiver first if you can.
  • Deactivate stops delivery without deleting anything. A deactivated endpoint receives no events until you activate it again.
  • Delete removes the endpoint for good.

The list shows each endpoint’s URL, name, events and status, so you can see at a glance what is subscribed to what.