A webhook tells your app when something happens in Workspace369, so you do not have to keep asking. When an event you chose occurs, Workspace369 sends a signed HTTPS POST to a URL you control. Your server checks the signature, does its work, and answers with a 2xx status.
Webhooks are available on the Specialist plan and above, including Commander and Enterprise. You manage them in the app, not through an API.
Create a webhook
- Open Settings and choose Webhooks.
- Choose New webhook.
- Enter the Endpoint URL. It must be an
https://address on the standard port, with no username or password, on a public host. - Under Events, select at least one event.
- Leave Active on, then save.
- Copy the Signing secret now. It starts with
whsec_and is shown only once.
Creating, changing, and rotating webhooks needs the Manage webhooks permission, which Managers and above have. To subscribe to a lead.* event you also need access to Requests, because those events carry the lead's contact details.
To replace a secret, choose Rotate secret on the webhook. The new secret is shown once and the old one stops working. Deleting a webhook stops its events immediately.
Events
Only these 21 events exist.
| Event | Sent when |
|---|---|
time_entry.created | A time entry is created. |
time_entry.updated | A time entry is changed. |
time_entry.deleted | A time entry is deleted. |
timesheet.submitted | A timesheet is submitted for approval. |
timesheet.approved | A timesheet is approved. |
timesheet.rejected | A timesheet is rejected. |
invoice.created | An invoice is created. |
invoice.paid | An invoice moves into Paid. Partial payments and reversals do not send it. |
invoice.status_changed | An invoice's status changes to any other status. A move into Paid sends this event first, then invoice.paid. |
proposal.sent | A proposal is sent. |
proposal.accepted | A client accepts a proposal. |
proposal.declined | A client declines a proposal. |
contract.sent | A contract is sent for signature. |
contract.recipient_completed | One recipient finishes signing. |
contract.completed | Every recipient has signed. |
contract.declined | A recipient declines. |
contract.voided | A contract is voided. |
contract.expired | A contract expires before it is signed. |
lead.created | A new request arrives. |
lead.converted | A request is converted to a client. |
lead.archived | A request is archived. |
There are no events for clients, projects, tasks, or bookings. To read those, call Workspace369 with a tool. See Call Workspace369 from your app over HTTPS.
What you receive
Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Id | A unique id for this event. The same event always has the same id, including on retries. |
X-Webhook-Timestamp | The send time in milliseconds since 1970, as text. |
X-Webhook-Signature | A hex HMAC-SHA256 signature. |
The body is an envelope:
{
"webhook_id": "...",
"event_type": "invoice.paid",
"entity_id": "...",
"workspace_id": "...",
"subscription_id": "...",
"data": { }
}
webhook_idis the same value as theX-Webhook-Idheader.entity_idis the id of the invoice, proposal, contract, time entry, timesheet, or request the event is about.dataholds the details of the record. Log a few real deliveries and build against what you see. Treat any field you do not recognize as optional.
Verify the signature
The signature is the HMAC-SHA256, in hex, of the raw request body followed immediately by the timestamp header text, using the whole signing secret as the key. The key includes the whsec_ prefix.
signature = hex( HMAC_SHA256( secret, rawBody + timestamp ) )
Compute it over the raw bytes you received. Do not parse the JSON and write it back out, because that can change the text and break the match.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.WORKSPACE369_WEBHOOK_SECRET; // whsec_...
const MAX_AGE_MS = 5 * 60 * 1000;
const seen = new Set(); // use a database or cache in production
const app = express();
app.post("/webhooks/workspace369", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const timestamp = req.get("X-Webhook-Timestamp") ?? "";
const signature = req.get("X-Webhook-Signature") ?? "";
const id = req.get("X-Webhook-Id") ?? "";
const expected = crypto
.createHmac("sha256", SECRET)
.update(rawBody + timestamp)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
if (Math.abs(Date.now() - Number(timestamp)) > MAX_AGE_MS) {
return res.status(401).send("stale");
}
if (seen.has(id)) return res.status(200).send("duplicate");
seen.add(id);
const event = JSON.parse(rawBody);
// Hand off the work quickly, then answer.
console.log(event.event_type, event.entity_id);
res.status(200).send("ok");
});
app.listen(3000);
The timestamp check is a safeguard we recommend. Workspace369 signs a fresh timestamp on every attempt, so a retry passes the check.
Retries and failures
- Answer with any 2xx status within 10 seconds. Anything else counts as a failure, including a timeout, a redirect, or a connection error. Redirects are not followed.
- After a failure, Workspace369 retries up to 5 more times, with longer waits between attempts. The same
X-Webhook-Idis used each time, so use it to ignore duplicates. - After the last attempt fails, the event is marked as dead and is not sent again.
- If the address can never be reached safely, such as a host that resolves to a private network address, Workspace369 switches the webhook off and notifies the workspace once.
- Events can arrive more than once and not always in order. Make your handler safe to run twice.
Plans and permissions
- Specialist and above. If a workspace moves to a lower plan, its webhooks stop sending and are kept as they were. They send again after an upgrade.
- Lead events.
lead.*events are sent only while the person who created the webhook still belongs to the workspace and can view Requests. If they lose that access, those events are skipped.
Check your webhook
- Create a test webhook that points at your server and subscribes to one event, such as
proposal.sent. - Trigger the event in Workspace369.
- Confirm your server received the request, the signature check passed, and it answered with 200.
- Send the same event again from your tool and confirm the duplicate is ignored.
If webhooks do not arrive
- Confirm the workspace is on Specialist or above.
- Confirm the webhook is Active and lists the event.
- Confirm the URL is
https://on the standard port and that your server answers within 10 seconds. - A signature mismatch is almost always caused by parsing the body before computing the signature, or by using a rotated-out secret.