Verifying signatures
Your webhook URL is a public HTTPS endpoint. Anyone who learns it can POST to it. The signature is the only thing separating an event we sent from one somebody else made up, so verify every request before you act on it.
The header
DriveCars-Signature: t=1788987793,v1=8bcb785698d217455d382e7d7d7cbccc8ceb12422438f0f211a45ae3f0ffd2b2Comma-separated key=value pairs:
| Pair | |
|---|---|
t | Unix timestamp in seconds when we signed. |
v1 | HMAC-SHA256(secret, "{t}.{rawBody}"), lowercase hex. |
To verify: recompute the HMAC over `${t}.${rawBody}` with your secret,
and compare against v1 in constant time.
The timestamp is inside the signed value, so it cannot be swapped for a fresh
one — changing t invalidates the signature with it.
There can be more than one v1
DriveCars-Signature: t=1788987793,v1=8bcb7856…,v1=c7889266…During a secret rotation we sign the same body with both the new and the old secret, on the same timestamp, and send both pairs. Whichever secret you currently hold, one of them matches.
This is the detail that breaks naive verifiers. Code that parses the
header with something like split(',')[1] and checks only the first v1
works perfectly — right up until the first rotation, at which point half your
events start failing verification for no visible reason. Loop over every v1
pair and accept if any matches.
Two other headers
DriveCars-Event-Id and DriveCars-Event-Type duplicate id and type from
the body; DriveCars-Delivery-Id identifies this particular attempt.
Convenient for logging and routing — but none of them are signed. Never
make a decision on a header value. Verify the signature, then read the body.
The raw body, not the parsed one
Sign against the exact bytes we sent. Not JSON.stringify(JSON.parse(body)),
not your framework’s re-encoded view of the object.
JSON round-tripping is not byte-stable: key order, whitespace and number formatting can all come back different, and the HMAC over different bytes is a different HMAC. This is the single most common cause of “verification fails and I cannot see why”.
Most frameworks need to be told to keep the raw body. In Express,
express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }); in
Flask, request.get_data() before touching request.json; in Laravel,
$request->getContent().
Node
import crypto from 'node:crypto';
function verifyDriveCarsSignature(header, rawBody, secrets, toleranceSeconds = 300) {
if (typeof header !== 'string' || header.length === 0) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
const t = Number(timestamp);
if (!Number.isFinite(t)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > toleranceSeconds) return false;
const candidates = Array.isArray(secrets) ? secrets : [secrets];
const signedPayload = `${timestamp}.${rawBody}`;
for (const secret of candidates) {
const expected = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
for (const candidate of signatures) {
if (candidate.length !== expected.length) continue;
let candidateBuf;
try {
candidateBuf = Buffer.from(candidate, 'hex');
} catch {
continue;
}
if (candidateBuf.length !== expectedBuf.length) continue;
if (crypto.timingSafeEqual(candidateBuf, expectedBuf)) return true;
}
}
return false;
}Note it uses timestamp — the original string — to rebuild the signed
payload, not the Number it parsed. "1788987793" and 1788987793 stringify
the same, but a header carrying t=1788987793.0 would not, and rebuilding
from the parsed number would silently produce a different HMAC than we signed.
Wired into Express:
import express from 'express';
const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }));
app.post('/drivecars', (req, res) => {
const ok = verifyDriveCarsSignature(
req.get('DriveCars-Signature'),
req.rawBody,
[process.env.DRIVECARS_WEBHOOK_SECRET, process.env.DRIVECARS_WEBHOOK_SECRET_PREVIOUS].filter(Boolean),
);
if (!ok) return res.status(400).send('bad signature');
enqueue(req.body); // durable write, fast
res.status(200).send(); // acknowledge, then do the real work
});Passing both the current and previous secret is what makes a rotation a non-event on your side.
PHP
<?php
function verify_drivecars_signature(
?string $header,
string $rawBody,
array $secrets,
int $toleranceSeconds = 300
): bool {
if ($header === null || $header === '') {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$eq = strpos($part, '=');
if ($eq === false) {
continue;
}
$key = trim(substr($part, 0, $eq));
$value = trim(substr($part, $eq + 1));
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || count($signatures) === 0) {
return false;
}
if (!is_numeric($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false;
}
$signedPayload = $timestamp . '.' . $rawBody;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $signedPayload, $secret);
foreach ($signatures as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
}
return false;
}hash_equals is the constant-time comparison. Do not substitute ===.
Python
import hashlib
import hmac
import time
def verify_drivecars_signature(header, raw_body, secrets, tolerance_seconds=300):
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
key = key.strip()
value = value.strip()
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
try:
t = float(timestamp)
except ValueError:
return False
if abs(time.time() - t) > tolerance_seconds:
return False
if isinstance(raw_body, str):
raw_body = raw_body.encode("utf-8")
signed_payload = timestamp.encode("utf-8") + b"." + raw_body
for secret in secrets:
if isinstance(secret, str):
secret = secret.encode("utf-8")
expected = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
for candidate in signatures:
if hmac.compare_digest(expected, candidate):
return True
return Falsehmac.compare_digest is the constant-time comparison. Note raw_body is
bytes — in Flask that is request.get_data(), read before anything
touches request.json.
What is proven, and what is not. The Node example is executed against our real signer on every CI run (
apps/api/test/docs-webhook-verifier-snippet.test.ts) — it accepts genuine single-secret and rotation-overlap signatures, and rejects tampered bodies, wrong secrets, stale timestamps and malformed headers.The Python example was checked the same way, by hand, against signatures the same signer produced — but it is not in CI, so it can drift.
The PHP example has not been executed. It is a line-by-line translation of the Node one, and we have no PHP runtime in this repository to run it in. Test it against a real delivery before you rely on it.
The tolerance window
The toleranceSeconds = 300 default is your choice, not a rule we
enforce. Nothing on our side rejects an old signature — the window exists so a
captured request cannot be replayed against you days later.
Five minutes is a reasonable default. Set it wider only if your clock skew genuinely warrants it; a very wide window makes the check close to meaningless. Make sure the host running your handler has NTP working — a clock that has drifted minutes off will reject perfectly valid deliveries, and the symptom looks identical to a wrong secret.
Rotating the secret
curl -sS -X POST "https://{your-base-url}/v1/webhooks/42/rotate-secret" \
-H "Authorization: Bearer $TOKEN"Returns the endpoint with a new secret field — returned once, here, the
same as at registration.
For 24 hours afterwards, every delivery is signed with both the new
secret and the old one, each as its own v1= pair. That overlap is what makes
rotation safe: you are not racing a deploy.
The sequence that works:
- Call
rotate-secret. Keep the old secret where it is. - Add the new secret to your verifier’s list, so it accepts both.
- Deploy that.
- Once deployed everywhere, drop the old secret.
Do it in that order and no delivery fails. Rotate first and deploy after, and everything signed with only the new secret fails verification until the deploy lands — those become retries, and a long enough gap disables the endpoint.
After the 24-hour overlap expires, the old secret stops being sent entirely. Finish step 3 well inside that window.
Rotating is also the only way to recover a secret you have lost — we cannot show you the existing one, because we do not store it in a readable form.
When verification fails
Work down this list before suspecting the signature:
- Are you hashing the raw bytes? Log the exact string you signed. The overwhelmingly likeliest cause is a re-serialised body.
- Are you checking every
v1pair? If failures started at a rotation, this is it. - Is your clock right? Compare
tagainst your host’s time. - Right endpoint’s secret? Each endpoint has its own. Two endpoints in one process need two secrets, keyed by which one received the request.
- Is the secret intact? A trailing newline from a copy-paste or a
.envfile changes the HMAC completely. It should look likewhsec_plus 32 hex characters, and nothing else.