Build with AI

Webhooks reference

The 21 events Workspace369 sends to your URL, the signed request format, a Node.js signature check, retries, and the plan and permission rules for webhooks.

View as Markdown

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

  1. Open Settings and choose Webhooks.
  2. Choose New webhook.
  3. Enter the Endpoint URL. It must be an https:// address on the standard port, with no username or password, on a public host.
  4. Under Events, select at least one event.
  5. Leave Active on, then save.
  6. 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.

EventSent when
time_entry.createdA time entry is created.
time_entry.updatedA time entry is changed.
time_entry.deletedA time entry is deleted.
timesheet.submittedA timesheet is submitted for approval.
timesheet.approvedA timesheet is approved.
timesheet.rejectedA timesheet is rejected.
invoice.createdAn invoice is created.
invoice.paidAn invoice moves into Paid. Partial payments and reversals do not send it.
invoice.status_changedAn invoice's status changes to any other status. A move into Paid sends this event first, then invoice.paid.
proposal.sentA proposal is sent.
proposal.acceptedA client accepts a proposal.
proposal.declinedA client declines a proposal.
contract.sentA contract is sent for signature.
contract.recipient_completedOne recipient finishes signing.
contract.completedEvery recipient has signed.
contract.declinedA recipient declines.
contract.voidedA contract is voided.
contract.expiredA contract expires before it is signed.
lead.createdA new request arrives.
lead.convertedA request is converted to a client.
lead.archivedA 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:

HeaderValue
Content-Typeapplication/json
X-Webhook-IdA unique id for this event. The same event always has the same id, including on retries.
X-Webhook-TimestampThe send time in milliseconds since 1970, as text.
X-Webhook-SignatureA hex HMAC-SHA256 signature.

The body is an envelope:

{
  "webhook_id": "...",
  "event_type": "invoice.paid",
  "entity_id": "...",
  "workspace_id": "...",
  "subscription_id": "...",
  "data": { }
}
  • webhook_id is the same value as the X-Webhook-Id header.
  • entity_id is the id of the invoice, proposal, contract, time entry, timesheet, or request the event is about.
  • data holds 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-Id is 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

  1. Create a test webhook that points at your server and subscribes to one event, such as proposal.sent.
  2. Trigger the event in Workspace369.
  3. Confirm your server received the request, the signature check passed, and it answered with 200.
  4. 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.

Was this article useful?

Your answer helps us decide what to clarify next.