Verify webhook signatures
Verify the signature of every webhook request before you act on it. Your endpoint’s address is not secret, so anyone could send it a request. Only Drum and you know the subscription’s secret.
Before you begin
You need the subscription’s secret. Copy it from the subscription’s page in Drum (Profile, then Webhooks), or from the secret returned when you created the subscription with the API.
1. Read the raw body
Read the request body as bytes, exactly as it arrived, before any JSON parsing. Parsing the JSON and encoding it again changes the bytes, and the signature no longer matches.
| Framework | Raw body |
|---|---|
| Rails | request.raw_post |
| Sinatra, Rack | request.body.read |
| Flask | request.get_data() |
| Django | request.body |
| FastAPI, Starlette | await request.body() |
| Express | express.raw({ type: "application/json" }) on the route, then req.body (a Buffer) |
2. Rebuild and compare the signature
Join the X-Webhook-Timestamp header, a full stop and the raw body. Compute the HMAC-SHA256 of that with the secret, in lower-case hex, and put sha256= in front. Compare it with X-Webhook-Signature in constant time.
Ruby:
def verify_drum_webhook(request, secret)
timestamp = request.headers["X-Webhook-Timestamp"].to_s
signature = request.headers["X-Webhook-Signature"].to_s
expected = "sha256=" + OpenSSL::HMAC.hexdigest("sha256", secret, "#{timestamp}.#{request.raw_post}")
fresh = (Time.now.to_i - timestamp.to_i).abs < 300
fresh && ActiveSupport::SecurityUtils.secure_compare(signature, expected)
end
Python:
import hashlib, hmac, time
def verify_drum_webhook(headers, raw_body: bytes, secret: str) -> bool:
timestamp = headers.get("X-Webhook-Timestamp", "")
signature = headers.get("X-Webhook-Signature", "")
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) < 300
return fresh and hmac.compare_digest(signature, expected)
Node.js:
const crypto = require("crypto");
function verifyDrumWebhook(headers, rawBody, secret) {
const timestamp = headers["x-webhook-timestamp"] || "";
const signature = headers["x-webhook-signature"] || "";
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return fresh &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
3. Reject old requests
The timestamp is part of what is signed, so it cannot be changed without breaking the signature. Rejecting a request whose timestamp is more than a few minutes old stops someone who captured a real request from sending it again later. The examples above allow 5 minutes either way.
Drum signs each attempt when it sends it, so a retried delivery carries a fresh timestamp and signature.
4. Answer a failed check with 401
Return 401 when the signature does not match. Drum treats a 401 as a failure that is not worth retrying, so a misconfigured secret shows up quickly as failed deliveries instead of repeated attempts.
If signatures never match
| Check | Why |
|---|---|
| The body is the raw bytes | A framework that parses JSON first, or a proxy that rewrites the body, changes the bytes |
| The secret belongs to this subscription | Each subscription has its own secret |
| The secret has no extra spaces or line breaks | Copying from a terminal or a password manager can add them |
| The header values are used exactly as sent | Including sha256= in the signature, and the timestamp as a string of digits |
| The hex digest is lower-case | Drum sends lower-case hex |