From 03f76d88d1036c4ef3369fe9fa14687b0c9dd178 Mon Sep 17 00:00:00 2001 From: Hannes Lenke Date: Wed, 28 Oct 2026 21:20:32 -0400 Subject: [PATCH] docs: describe the webhook signature as HMAC-SHA256 over the raw body and verify raw bytes in the Node sample (monorepo #4904) The alerting daemon signs each webhook with HMAC-SHA256 over the exact body bytes it sends, hex-encoded in x-checkly-signature. The page called this a "SHA256 hash" of the payload, and the Node.js sample re-serialised the parsed body with JSON.stringify, which cannot verify plain text bodies and breaks whenever the serialisation differs from the sent bytes. The sample now reads the raw body with express.raw(). One sentence documents that JSON payload templates stay valid JSON when an inserted value contains a double quote or a line break, and how {{VAR}} and {{{VAR}}} render a double quote. Proven on the local stack at monorepo 5858d7e03f: three webhook jobs built by the alerting daemon, replayed byte for byte to a local receiver; 3/3 signatures match HMAC-SHA256(secret, raw bytes); flipped byte, plain SHA256 and the old text rule mismatch; old sample fails the text body, new sample passes all. Co-Authored-By: Claude Opus 5.5 --- integrations/alerts/webhooks.mdx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/integrations/alerts/webhooks.mdx b/integrations/alerts/webhooks.mdx index 807bfc2f..6b7ce169 100644 --- a/integrations/alerts/webhooks.mdx +++ b/integrations/alerts/webhooks.mdx @@ -32,6 +32,8 @@ In both cases we use the familiar Handlebars templating braces, i.e. `{{ }}` to To avoid encoding, you can access your environment variables with triple brackets, i.e. `{{{USER_API_KEY}}}` +A JSON payload template stays valid JSON when an inserted value contains a double quote or a line break: the value is escaped in the body we send. With `{{CHECK_NAME}}` a double quote arrives as `"`, with `{{{CHECK_NAME}}}` as `\"`; a line break arrives as `\n` in both cases. + You can use the following event-related variables in both URL and payload. @@ -175,9 +177,9 @@ You can validate each webhook we deliver to your endpoint(s). Using the optional 1. Check if the webhook was sent by Checkly. 2. Check if the payload was not altered in any way during transmission. -When you create a webhook secret, we proceed to use that secret token to cryptographically sign the webhook payload using the SHA256 hash algorithm. We add the resulting hash to the HTTP header `x-checkly-signature` on each webhook. +When you create a webhook secret, we use it to sign each webhook: the HTTP header `x-checkly-signature` contains the HMAC-SHA256 of the raw request body, keyed with your secret and hex-encoded. The signature covers the exact bytes we send, for JSON and plain text bodies alike. -On the receiving end, you can then use the value of the `x-checkly-signature` header to assert the validity and authenticity of the webhook and its payload. +On the receiving end, compute the same HMAC over the raw request body, before parsing it, and compare it with the `x-checkly-signature` header to assert the validity and authenticity of the webhook and its payload. Parsing and re-serializing the body can change its bytes and break the comparison. Have a look at the code examples below on how to use the header and your favourite web framework. @@ -185,9 +187,9 @@ Have a look at the code examples below on how to use the header and your favouri ```javascript Node.js // We store the webhook secret in an environment variable called CHECKLY_WEBHOOK_SECRET -const app = require('express')(); -const bodyParser = require('body-parser'); +const express = require('express'); const crypto = require('crypto'); +const app = express(); function isVerifiedPayload (payload, signature) { const secret = process.env.CHECKLY_WEBHOOK_SECRET @@ -196,10 +198,11 @@ function isVerifiedPayload (payload, signature) { return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature)) } -app.post('/webhook', bodyParser.json({ type: 'application/json' }), (request, response) => { +// express.raw() keeps the body as the bytes Checkly sent; parse it only after the signature is verified +app.post('/webhook', express.raw({ type: '*/*' }), (request, response) => { const signature = request.headers['x-checkly-signature']; - const payload = JSON.stringify(request.body) + const payload = request.body if (isVerifiedPayload(payload, signature)) { console.log('Signature is valid')