Skip to content

Verify a webhook

Use this helper in a Node.js receiver. Download verify-webhook.mjs. It uses only the built-in cryptography module.

verify-webhook.mjs
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyWebhook({ rawBody, signature, eventId, secret, now = Date.now() }) {
if (!Buffer.isBuffer(rawBody) || rawBody.length > 256 * 1024) throw new Error('Invalid webhook body')
if (typeof secret !== 'string' || !secret) throw new Error('Missing signing secret')
if (typeof signature !== 'string' || typeof eventId !== 'string' || !eventId) throw new Error('Missing webhook headers')
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(signature)
if (!match) throw new Error('Invalid signature header')
const timestamp = Number(match[1])
if (!Number.isSafeInteger(timestamp) || !Number.isFinite(now) || Math.abs(now / 1000 - timestamp) > 300) {
throw new Error('Webhook timestamp outside tolerance')
}
const expected = createHmac('sha256', secret).update(`${match[1]}.`).update(rawBody).digest()
if (!timingSafeEqual(expected, Buffer.from(match[2], 'hex'))) throw new Error('Invalid webhook signature')
const event = JSON.parse(rawBody.toString('utf8'))
if (!event || typeof event !== 'object' || event.id !== eventId) throw new Error('Event ID mismatch')
return event
}

Read the request body as a Buffer, with a size limit enforced while reading. Do not let JSON middleware transform it before verification. Pass the raw body, X-EthicsPortal-Signature, X-EthicsPortal-Event-Id and your secret to verifyWebhook.

const event = verifyWebhook({
rawBody,
signature: request.headers['x-ethicsportal-signature'],
eventId: request.headers['x-ethicsportal-event-id'],
secret: process.env.ETHICSPORTAL_WEBHOOK_SECRET
})

Catch verification failures and return a non-2xx response. Do not log the secret or full body. A valid signature authenticates a delivery; it does not make the delivery unique.

In your application’s database, insert the verified event into a durable inbox with a unique event ID. On a duplicate, return 2xx without inserting or applying it again. Return 2xx for a new event only after the inbox transaction commits. If storage is unavailable, return a failure status.

A worker can then process the inbox with idempotent business operations. This helper deliberately handles verification only; the durable inbox belongs in your application. Delivery and retry semantics.