Verifying signatures
Verifying deliveries are genuine
Set a signing secret in Integrations and verify it on your side before acting on a payload.
An endpoint URL is not a secret — it can be guessed, logged by a proxy, or leak from a config file. Without signature verification, anyone who learns the URL can post fabricated orders to your integration. With it, a forged request fails the check and is discarded.
Verify the signature before parsing or acting on the body, not after.
What every delivery carries
| Header | Example | Always sent? |
|---|---|---|
webhook-id |
msg_4e9c1b7a2f08d3516ac4be9017d2f5a3 |
Yes |
webhook-timestamp |
1788112371 (Unix seconds) |
Yes |
webhook-signature |
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
Only when a secret is set |
X-NextRestaurant-Event |
order.placed |
Yes |
X-NextRestaurant-Signature |
sha256=6f2b… |
Only when a secret is set |
Two signature schemes go out on every signed delivery, over the same body with the same key. Verify either one — not both.
Standard Webhooks (use this for new integrations)
webhook-signature follows the Standard Webhooks
specification, so any library implementing it will verify our deliveries with no
custom code.
The signed content is the three parts joined by full stops:
{webhook-id}.{webhook-timestamp}.{raw body}
The signature is the base64 of the HMAC-SHA256 of that string — base64, not hex, which is the one difference from the legacy header below.
$body = file_get_contents('php://input');
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$ts = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
// Reject anything stale BEFORE checking the signature. A signature stays valid
// forever, so without this a delivery someone captured can be replayed at you
// indefinitely and it will verify perfectly every time.
if (!$ts || abs(time() - (int) $ts) > 300) {
http_response_code(400);
exit;
}
$expected = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$ts.$body", $YOUR_KEY, true));
// The header is a space-delimited list so a key rotation can send more than one.
// Split it and accept if any entry matches — do not compare the whole header.
$sent = explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');
$ok = false;
foreach ($sent as $candidate) {
if (hash_equals($expected, $candidate)) {
$ok = true;
}
}
if (!$ok) {
http_response_code(401);
exit;
}
Your signing secret
Generate secret mints one in the form whsec_ followed by base64. For a whsec_
secret the HMAC key is the decoded bytes, not the string you can see — that is what
Standard Webhooks libraries expect, and they handle the decoding for you.
If your secret was typed in by hand rather than generated, it is used as-is: the raw bytes of the literal string. Nothing about existing secrets changed.
Rotating the secret invalidates signatures under both schemes, which is what rotation means. Update your consumer at the same time.
The original header
X-NextRestaurant-Signature is unchanged and stays supported. Its value is sha256=
followed by the hex HMAC-SHA256 of the raw body, keyed with your signing secret.
Compute it over the bytes as received — re-serialising the JSON first will change them
and the check will fail.
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $YOUR_SECRET);
if (!hash_equals($expected, $_SERVER['HTTP_X_NEXTRESTAURANT_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
This scheme has no timestamp in what it signs, so it cannot tell a replay from a fresh delivery on its own. That is the reason to prefer the Standard Webhooks header.
With no secret set, deliveries go out unsigned
Both signature headers are simply absent — webhook-id and webhook-timestamp are
still sent — and a consumer that verifies will reject every delivery. This is the
state a new install ships in. Generate the secret before you write the verification,
and check the delivery log below to confirm what is actually being sent.