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.
Get your Webhook Key
Section titled “Get your Webhook Key”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.
How Crewsum signs a request
Section titled “How Crewsum signs a request”Every request has two headers:
| Header | Value |
|---|---|
x-timestamp | The time Crewsum signed the request, in Unix seconds (for example, 1790000000). |
x-crewsum-signature | The 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.
Validation steps
Section titled “Validation steps”-
Read the
x-timestampandx-crewsum-signatureheaders and the raw request body. If any of them is missing, reject the request. -
Check that the timestamp is recent. The examples below allow 5 minutes. This limits replay attacks. Crewsum does not require a specific window.
-
Compute the HMAC-SHA256 of
<x-timestamp>.<raw body>with your Webhook Key, as lowercase hex. -
Compare your result with
x-crewsum-signaturewith a constant-time comparison. Reject the request with401if they are different. -
Parse the body as JSON and handle the event. Respond with a
2xxstatus quickly. See Delivery.
Examples
Section titled “Examples”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);import hashlibimport hmacimport jsonimport osimport time
from flask import Flask, abort, request
app = Flask(__name__)
# "Your Webhook Key" from Crewsum.WEBHOOK_KEY = os.environ.get("CREWSUM_WEBHOOK_KEY", "")TOLERANCE_SECONDS = 300
def is_valid_signature(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool: if not raw_body or not signature or not timestamp: return False
try: sent_at = int(timestamp) except ValueError: return False if abs(int(time.time()) - sent_at) > TOLERANCE_SECONDS: return False
signed = timestamp.encode() + b"." + raw_body expected = hmac.new(WEBHOOK_KEY.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest is constant-time and returns False when the lengths differ. return hmac.compare_digest(expected.encode(), signature.encode())
@app.post("/webhooks/crewsum")def crewsum_webhook(): # Read the raw bytes before anything touches request.form or request.json. raw_body = request.get_data()
if not is_valid_signature( raw_body, request.headers.get("x-crewsum-signature"), request.headers.get("x-timestamp"), ): abort(401)
payload = json.loads(raw_body) event, data = payload["event"], payload["data"]
if event == "projects.created": pass # data["projects"] is a list of projects. elif event == "timesheet.submitted": pass # data["timesheet"] is the submitted timesheet. elif event == "debug": pass # Sent by "Send test" and "Send Event" in Crewsum.
return {"status": "ok"}Test your endpoint
Section titled “Test your endpoint”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
401for a changed body, a wrong signature, a signature of the wrong length, or a missing header. - Returns
401for a timestamp outside your window.
Security considerations
Section titled “Security considerations”- 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 anhttps://URL so that payloads are encrypted in transit. - Do not log the key or the signature. Log the event type and the IDs in
datainstead.