Skip to content

Validating Webhook Signatures

Crewsum signs every webhook request with your Webhook Key. Check the signature before you trust a request, so that only Crewsum can trigger your integration.

Each webhook has its own key. To see it, open Developer > Webhooks in Crewsum, open the row menu (…) for the webhook, and select Edit webhook. The key is under Your Webhook Key. See Creating a Webhook.

Crewsum never sends the key in a request. Store it as a secret on your server, for example in an environment variable.

Every request has two headers:

HeaderValue
x-timestampThe time Crewsum signed the request, in Unix seconds (for example, 1790000000).
x-crewsum-signatureThe HMAC-SHA256 of the signed string, as 64 lowercase hex characters.

The signed string is the x-timestamp value, a period, and the raw request body, exactly as received:

<x-timestamp>.<raw body>

The HMAC key is your Webhook Key as text. The key looks like 64 hex characters, but do not hex-decode it. Use the string itself.

  1. Read the x-timestamp and x-crewsum-signature headers and the raw request body. If any of them is missing, reject the request.

  2. Check that the timestamp is recent. The examples below allow 5 minutes. This limits replay attacks. Crewsum does not require a specific window.

  3. Compute the HMAC-SHA256 of <x-timestamp>.<raw body> with your Webhook Key, as lowercase hex.

  4. Compare your result with x-crewsum-signature with a constant-time comparison. Reject the request with 401 if they are different.

  5. Parse the body as JSON and handle the event. Respond with a 2xx status quickly. See Delivery.

Both examples accept a request only when the signature is valid. They return 401 for a missing, malformed, or wrong signature, including a signature of the wrong length.

import crypto from "node:crypto";
import express, { type Request, type Response } from "express";
const app = express();
// "Your Webhook Key" from Crewsum.
const WEBHOOK_KEY = process.env.CREWSUM_WEBHOOK_KEY ?? "";
const TOLERANCE_SECONDS = 300;
function isValidSignature(
rawBody: Buffer,
signature: string | undefined,
timestamp: string | undefined,
): boolean {
if (!rawBody.length || !signature || !timestamp) return false;
const sentAt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_SECONDS) {
return false;
}
const expected = crypto
.createHmac("sha256", WEBHOOK_KEY)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
// timingSafeEqual throws when the lengths differ, so check them first.
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw() keeps the exact bytes for every content type.
// Register this route before any app-wide body parser such as express.urlencoded().
app.post(
"/webhooks/crewsum",
express.raw({ type: "*/*" }),
(req: Request, res: Response) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (
!isValidSignature(
rawBody,
req.get("x-crewsum-signature"),
req.get("x-timestamp"),
)
) {
res.status(401).json({ error: "Invalid webhook signature" });
return;
}
const { event, data } = JSON.parse(rawBody.toString("utf8"));
switch (event) {
case "projects.created":
// data.projects is an array of projects.
break;
case "timesheet.submitted":
// data.timesheet is the submitted timesheet.
break;
case "debug":
// Sent by "Send test" and "Send Event" in Crewsum.
break;
default:
console.log(`Unhandled event: ${event}`, data);
}
res.status(200).json({ status: "ok" });
},
);
app.listen(3000);

In Crewsum, open Developer > Webhooks, open the row menu (…) for your webhook, and select Send test. You can also click Send Event on the Edit Webhook panel. Crewsum sends a signed request with this body:

{ "event": "debug", "data": { "message": "Debug Event" } }

The test request is signed like a real event. Crewsum sends it even when the webhook is not subscribed to any event. You can send 10 test requests per minute.

The message “Successfully sent debug event to webhook” means only that Crewsum sent the request. It does not mean that your endpoint accepted it, so check your endpoint’s logs.

Also check that your endpoint:

  • Accepts a request with a valid signature.
  • Returns 401 for a changed body, a wrong signature, a signature of the wrong length, or a missing header.
  • Returns 401 for a timestamp outside your window.
  • Keep the key secret. Store it in a secret manager or an environment variable. Do not commit it to source control.
  • Rotate the key if it leaks. Click Regenerate Key on the Edit Webhook panel. The new key is used from the next request, and the old key stops working at once. Update your endpoint right after you regenerate the key.
  • Use HTTPS. Crewsum accepts http:// URLs, but use an https:// URL so that payloads are encrypted in transit.
  • Do not log the key or the signature. Log the event type and the IDs in data instead.