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.
What Dakoty sends to verify
Section titled “What Dakoty sends to verify”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.
Rules every verifier must follow
Section titled “Rules every verifier must follow”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.
Verify with the Node.js library
Section titled “Verify with the Node.js library”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);Verify with the Python library
Section titled “Verify with the Python library”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, requestfrom 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")))Verify with the PHP library
Section titled “Verify with the PHP library”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.
<?phprequire __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 afterwardserror_log('verified ' . $event['id'] . ' ' . $event['type']);Verify by hand in Node.js
Section titled “Verify by hand in Node.js”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); });}The legacy X-Dakoty-Signature header
Section titled “The legacy X-Dakoty-Signature header”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.
Legacy check in Node.js
Section titled “Legacy check in Node.js”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);}Legacy check in Python
Section titled “Legacy check in Python”The same check in Python. Pass request.get_data() as raw_body and the header value as header.
import hashlibimport hmacimport 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)Legacy check in PHP
Section titled “Legacy check in PHP”The same check in PHP. Pass the contents of php://input as $rawBody, and $_SERVER['HTTP_X_DAKOTY_SIGNATURE'] as $header.
<?phpfunction 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']);}Test your verifier with a known vector
Section titled “Test your verifier with a known vector”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.