Preskočiť na obsah

Verifying webhook signatures

Anyone who knows your endpoint URL can post to it, so check the signature of every delivery before you trust it. Dakoty signs deliveries with the Standard Webhooks scheme, and also sends the older X-Dakoty-Signature header for compatibility. You find the signing secret (it starts with whsec_) in the webhook detail in the app, and a REST hook gets it once in the create response.

Each delivery carries webhook-id, webhook-timestamp and webhook-signature. The signature is v1, followed by the base64 of HMAC-SHA256 over the string webhook-id.webhook-timestamp.raw-body. The HMAC key is the base64-decoded part of the secret after whsec_. During a key rotation with overlap the header holds two space-separated signatures, one per key, and it is enough that one of them matches.

Compute the signature from the raw request body exactly as received, before any JSON parsing, because re-serializing changes the bytes. Reject deliveries whose webhook-timestamp is more than five minutes away from your clock, which stops replayed captures. Compare signatures with a constant-time function, never with ==. Ignore an event id you have already processed. The libraries below do the first three for you.

Install standardwebhooks and express (npm install standardwebhooks express). The route must receive the raw body, so use express.raw for it and not express.json. Pass the Buffer and the headers object to verify, which throws on any problem and returns the parsed envelope.

import express from "express";
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.DAKOTY_WEBHOOK_SECRET); // whsec_...
const app = express();
app.post("/dakoty/webhook", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers);
} catch {
return res.status(400).send("invalid signature");
}
res.sendStatus(200); // acknowledge first, process afterwards
console.log("verified", event.id, event.type);
});
app.listen(Number(process.env.PORT) || 3000);

Install it with pip install standardwebhooks flask. Read the body with request.get_data(), which returns the raw bytes, and pass the headers as a dictionary. The library lowercases header names for you.

import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["DAKOTY_WEBHOOK_SECRET"]) # whsec_...
app = Flask(__name__)
@app.post("/dakoty/webhook")
def dakoty_webhook():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except (WebhookVerificationError, ValueError):
return "invalid signature", 400
print("verified", event["id"], event["type"])
return "", 200
if __name__ == "__main__":
app.run(port=int(os.environ.get("PORT", "3000")))

Install it with composer require standard-webhooks/standard-webhooks. Read php://input for the raw body and give the library lowercase header names, because getallheaders() returns them in their original case.

<?php
require __DIR__ . '/vendor/autoload.php';
use StandardWebhooks\Webhook;
use StandardWebhooks\Exception\WebhookVerificationException;
$payload = file_get_contents('php://input'); // raw body, before any JSON parsing
$headers = [];
foreach (getallheaders() as $name => $value) {
$headers[strtolower($name)] = $value;
}
try {
$wh = new Webhook(getenv('DAKOTY_WEBHOOK_SECRET')); // whsec_...
$event = $wh->verify($payload, $headers);
} catch (WebhookVerificationException $e) {
http_response_code(400);
exit('invalid signature');
}
http_response_code(200); // acknowledge first, process afterwards
error_log('verified ' . $event['id'] . ' ' . $event['type']);

If you cannot add a dependency, this function does the whole check with the built-in crypto module. Pass the raw Buffer, not the parsed object, and a headers object with lowercase names, as Node’s req.headers provides.

import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDakoty(secret, rawBody, headers, toleranceSeconds = 300) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
const signatures = headers["webhook-signature"];
if (!id || !signatures || !Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
return signatures.split(" ").some((part) => {
const [version, signature] = part.split(",");
if (version !== "v1" || !signature) return false;
const received = Buffer.from(signature, "base64");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}

Every delivery also carries X-Dakoty-Signature: t=<unix seconds>,v1=<hex>, so integrations written against the older docs keep working. The hex value is HMAC-SHA256 over <t>.<raw body>. The key is the secret string itself as UTF-8 bytes, including the whsec_ prefix, and it is not base64-decoded. A webhook marked as legacy format (older order webhooks) sends only this header. New code should prefer webhook-signature; migrate a legacy webhook in the app and change the receiver at the same time, because the body changes.

This checks X-Dakoty-Signature with the same rules: raw body, a five-minute tolerance and constant-time comparison.

import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLegacy(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
if (!parts.v1 || !Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret) // the full secret string, whsec_ prefix included
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(parts.v1);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}

The same check in Python. Pass request.get_data() as raw_body and the header value as header.

import hashlib
import hmac
import time
def verify_legacy(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts["t"])
received = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)

The same check in PHP. Pass the contents of php://input as $rawBody, and $_SERVER['HTTP_X_DAKOTY_SIGNATURE'] as $header.

<?php
function verify_legacy(string $secret, string $rawBody, string $header, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$name, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$name] = $value;
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}

The Standard Webhooks project publishes this test vector, which Dakoty’s signer is checked against. With secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek, webhook-timestamp 1614265330 and the body {"test": 2432232314}, the correct webhook-signature is v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=. The timestamp is old, so run the check with the tolerance disabled or with a fixed clock. For a live test use the test button on the webhook in the app, or a REST hook sample from Webhooks.