Skip to content

Receive a completed-job webhook

When a job is completed, WorkWing can post the details to a URL of yours. This recipe builds a receiver that checks each request really came from WorkWing, answers fast enough that WorkWing doesn’t count it as a failure, and acts on each event once even when it arrives twice.

Webhooks is the reference for every event, body and header. This page puts those pieces together for one event, job.completed.

You need the Tenant Admin role, or a custom role that includes managing webhooks.

  1. Go to Settings > Webhooks and click Create Webhook.
  2. Enter your Endpoint URL. It must be HTTPS and reachable from the public internet. To test from your own machine, put a tunnelling service in front of it.
  3. Under Event Types, tick job.completed. Tick only what this receiver handles, rather than leaving the list empty to take everything.
  4. Click Create Webhook, and copy the signing secret it shows you. It’s shown once. Store it where your receiver reads its configuration, such as an environment variable.

Don’t tick job.status_changed on the same endpoint as well. A completion sends both, so you’d get every completion twice under two different event ids.

An endpoint receives events for every brand in your organisation. Each body carries its brand_id, so check it if this receiver is only meant for one brand.

Each request is a POST with a JSON body and these headers:

Content-Type: application/json
X-Webhook-Signature: t=1790000000,v1=3f1c9a...e07b
X-Webhook-Event: job.completed
X-Webhook-Idempotency-Key: 0198f0c4-5a11-7c62-9d84-3e7b1f0c8a25

The body, trimmed to the fields this recipe uses:

{
"id": "0198f0c4-5a11-7c62-9d84-3e7b1f0c8a25",
"event": "job.completed",
"version": "2026-09-12",
"created_at": "2026-09-09T11:04:18.902Z",
"brand_id": "0198f0c4-5a11-7c62-9d84-000000000002",
"data": {
"job": {
"id": "0198f0c4-5a11-7c62-9d84-000000000003",
"number": "JOB-00042",
"status": "T_COMPLETED",
"customer_id": "0198f0c4-5a11-7c62-9d84-000000000005",
"completed_at": "2026-09-09T10:38:00.000Z",
"kind": "T_STANDARD",
"parent_job_id": null
}
}
}

The full body is in What a request looks like. It carries ids and statuses, not names, addresses or notes. Fetch those from the API when you process the event.

A project sends job.completed too, when it’s signed off, and so does each job inside a project as it’s finished. kind and parent_job_id tell you which you’ve got:

kind T_PROJECT a project being signed off
kind T_STANDARD, parent_job_id null an ordinary job
kind T_STANDARD, parent_job_id set a job inside the project it names

X-Webhook-Signature holds two parts: t, the time the request was sent in Unix seconds, and v1, the HMAC-SHA256 of the timestamp, a full stop and the exact request body, keyed with your signing secret and written as lowercase hex. Work it out yourself over the raw bytes, compare, and refuse a timestamp more than five minutes away from your own clock.

import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function isFromWorkWing(rawBody, signatureHeader, signingSecret) {
if (typeof signatureHeader !== "string") return false;
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader);
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);
}

Three things trip people up:

  • Sign the raw body. Parse the JSON and encode it again and the bytes change, so the signature won’t match. Most frameworks parse JSON for you, so ask for the raw body on this route.
  • Check the timestamp against t, not the body. t is when this attempt was sent, and it’s inside the signed material, so it can’t be changed without breaking the signature. A captured request sent again later fails the five-minute window. Don’t reject a request because the body’s created_at is old: a retry or a Resend from the delivery log carries the original body, possibly days later, but is signed afresh with a current t.
  • Keep your clock right. The window is measured against your server’s clock, so a server that has drifted by minutes refuses genuine requests. Run NTP.

The window stops a stale request being replayed. Refusing an event id you’ve already handled, below, stops a request being acted on twice inside it.

Every attempt is signed with the secret the endpoint holds when that attempt is sent. Rotate Secret replaces it straight away, so retries of earlier events arrive signed with the new one. Update your receiver as soon as you rotate.

WorkWing waits ten seconds for your answer. Anything other than a 2xx in that time is a failed attempt, and it tries again with a growing gap between attempts, up to eight attempts in all. An endpoint that’s still failing every attempt a day later is switched off, as described in When WorkWing switches an endpoint off, and events raised while it’s off are lost.

So don’t do the work while WorkWing waits. Check the signature, store the event, answer 200, and do the real work afterwards. Fetching records from the API, calling another system or sending email all belong after the answer.

Answer 200 for a duplicate too. A repeat means an earlier answer didn’t reach WorkWing in time, and anything other than a 2xx only brings it back again.

The receiver below writes each event into a table keyed on the event id and answers. The insert ignores an id it already holds, so a repeat costs nothing and is never processed twice. It uses Express and PostgreSQL, but the shape is the same in any stack: a unique key on the event id, and an insert that does nothing on a clash.

CREATE TABLE workwing_events (
event_id text PRIMARY KEY,
event_type text NOT NULL,
body jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
import express from "express";
import postgres from "postgres";
import { isFromWorkWing } from "./signature.js";
const sql = postgres(process.env.DATABASE_URL);
const app = express();
app.post(
"/webhooks/workwing",
express.raw({ type: "application/json" }),
async (req, res) => {
const ok = isFromWorkWing(
req.body,
req.get("X-Webhook-Signature"),
process.env.WORKWING_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
const eventId = req.get("X-Webhook-Idempotency-Key");
await sql`
INSERT INTO workwing_events (event_id, event_type, body)
VALUES (${eventId}, ${event.event}, ${sql.json(event)})
ON CONFLICT (event_id) DO NOTHING
`;
res.sendStatus(200);
},
);
app.listen(3000);

A request that fails the signature check gets 401. That’s the right answer, but WorkWing treats it as a failure and tries again, and the attempts show as Failed in the delivery log. If every request is failing the check, the receiver’s secret is wrong: rotate it and update the receiver before the endpoint is switched off.

X-Webhook-Idempotency-Key is the same value as the body’s id. It’s the same on every attempt at one event, including a Resend, and different for every other event. It’s opaque, so store it and compare it for equality without parsing it.

A separate worker picks up stored events and does the real work, at whatever pace it needs.

async function processPending() {
const rows = await sql`
SELECT event_id, body FROM workwing_events
WHERE processed_at IS NULL AND event_type = 'job.completed'
ORDER BY received_at
LIMIT 50
`;
for (const { event_id, body } of rows) {
await handleCompletedJob(body.data.job);
await sql`
UPDATE workwing_events SET processed_at = now() WHERE event_id = ${event_id}
`;
}
}

When handleCompletedJob throws, the event stays unprocessed and the next pass tries it again. That’s your retry, on your schedule, and it has nothing to do with WorkWing’s.

Two things to build into handleCompletedJob:

Read the job, not the event. Events arrive in no particular order, and the body says how the job looked when the event was built. When what you do depends on how the job stands now, fetch it from the API with the id in the body. Sync completed jobs to another system does exactly that.

Decide what “once” means for you. The event id stops you handling one event twice. It doesn’t stop one job completing twice: someone with permission to reopen a completed job can reopen it, and when it’s completed again that’s a new event with a new id. If the work you do must happen once per job, such as raising an invoice, keep a record keyed on the job id as well and check it first.

There’s no button that sends a test event. Complete a test job instead, then open Deliveries on the webhook’s row in Settings > Webhooks to see each attempt, the status your endpoint answered with, and the start of what it sent back.

To check the receiver drops duplicates, have it answer 500 once, then click Resend on that failed attempt. The resend carries the same X-Webhook-Idempotency-Key, so the event should be stored once and processed once.