Verify webhook signatures

Check that a request came from Dialgem and was not replayed, with the SDK or a few lines of your own, and rotate secrets safely.

Anyone can send a POST to your URL. The signature is how you know a request really came from Dialgem and has not been changed or replayed. Always verify before you trust a body.

Custom function requests are signed the same way, so one verifier covers both.

Headers

Every request carries:

HeaderValue
X-Dialgem-Signaturev=<timestamp_ms>,d=<hex>
X-Dialgem-EventThe event name.
X-Dialgem-DeliveryAn id that is the same on every retry of one delivery.
X-Dialgem-Signature-PreviousOnly during a secret rotation. See below.

How the signature is made

text
hex = HMAC-SHA256(secret, raw_body + timestamp_ms)
  • raw_body is the exact bytes of the request body.
  • timestamp_ms is the number from the v= part of the header, written as decimal text, and appended to the body.
  • The result is lowercase hex, and it is the d= part of the header.

Because the timestamp is part of the signed message, an attacker cannot replay a captured request with a fresh timestamp. Reject a signature whose timestamp is more than five minutes away from your clock.

With the SDK

import { Dialgem } from "@dialgem/sdk"

const event = await Dialgem.webhooks.constructEvent(rawBody, req.headers["x-dialgem-signature"], secret)

Both reject signatures older than five minutes. Change that with toleranceMs in TypeScript or tolerance_ms in Python. The SDK compares in constant time and throws or raises when the signature is wrong.

By hand

If you do not use an SDK, this is the whole check.

import { createHmac, timingSafeEqual } from "node:crypto"

export function verify(rawBody: Buffer, header: string, secret: string, toleranceMs = 5 * 60 * 1000) {
  const m = /^v=(\d{10,16}),d=([0-9a-f]{64})$/.exec(header.trim().toLowerCase())
  if (!m) return false
  const [, timestamp, received] = m
  if (Math.abs(Date.now() - Number(timestamp)) > toleranceMs) return false

  const expected = createHmac("sha256", secret)
    .update(rawBody)
    .update(timestamp)
    .digest("hex")

  return timingSafeEqual(Buffer.from(expected), Buffer.from(received))
}

Use a constant-time comparison, as above, not ==.

Rotate the secret

If a secret leaks, or on a schedule, choose Rotate secret on the endpoint. You get a new secret.

For the overlap period you choose (24 hours by default, up to 168, and 0 ends the old secret at once) each request carries two signatures:

  • X-Dialgem-Signature, made with the new secret.
  • X-Dialgem-Signature-Previous, made with the old secret.

So the order is: rotate, deploy the new secret to your receiver, and nothing is dropped in between. A receiver that still has only the old secret can verify against X-Dialgem-Signature-Previous until the overlap ends.

When verification fails

CauseFix
A framework parsed and re-serialized the JSONVerify against the raw bytes.
The wrong secret, for example another endpoint'sEach endpoint has its own secret.
A trailing newline or character encoding changeDo not trim or re-encode the body.
Your server clock is more than five minutes offSync it with NTP.
A proxy changed the bodyMake sure it forwards the body unchanged.

If you are stuck, use Send a test event on the endpoint and compare what you compute with the header.