webhook-id: 7c1e2f0a-3b5d-4e8f-9a61-2d4c8b0e5f13
webhook-timestamp: 1790490600
webhook-signature: v1,s0RzLJ1d7Cxd9nwe8pqER9DWy/stEIB+uT9MlGVLlM0=
| Header |
What it is |
| webhook-id |
The delivery, the same value as delivery_id in the body. Remember the ones you have handled, and answer a repeat with 200 without doing the work again. |
| webhook-timestamp |
When we signed it, in whole seconds since 1970. Refuse anything more than five minutes from your own clock, so an old delivery cannot be sent to you again. |
| webhook-signature |
v1, then the base64 HMAC-SHA256 of the id, the timestamp and the raw body joined with full stops, made with your signing key. There can be more than one, separated by spaces. Accept the delivery if any of them matches. |
The signing key
It starts with whsec_. Take that off and base64-decode the rest: those bytes are the HMAC key, not the text you copied. It is shown once, when you connect the webhook or press "New signing key" on the Publishing page, and we never send it.
Check the raw body
Work out the signature from the bytes exactly as they arrived, before anything parses them. JSON that has been decoded and encoded again is not the same bytes, and its signature will not match.
PHP
<?php
$raw = getenv('SEONIS_SIGNING_KEY'); // whsec_...
// No key, no deliveries. A missing or empty variable would otherwise become
// an empty HMAC key, and anybody can sign with that.
$secret = is_string($raw) && str_starts_with($raw, 'whsec_')
? base64_decode(substr($raw, strlen('whsec_')), true)
: false;
if ($secret === false || strlen($secret) < 24) {
http_response_code(500);
exit('Signing key not configured.');
}
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$header = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit('Too old.');
}
$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $secret, true));
$valid = false;
foreach (explode(' ', $header) as $signature) {
[$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
$valid = $valid || ($version === 'v1' && hash_equals($expected, $value));
}
if (! $valid) {
http_response_code(401);
exit('Bad signature.');
}
$delivery = json_decode($body, true);
Node.js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// No key, no server. An empty variable would otherwise become an empty HMAC
// key, and anybody can sign with that.
const raw = process.env.SEONIS_SIGNING_KEY ?? '';
if (!raw.startsWith('whsec_')) throw new Error('SEONIS_SIGNING_KEY is not set.');
const key = Buffer.from(raw.slice('whsec_'.length), 'base64');
if (key.length < 24) throw new Error('SEONIS_SIGNING_KEY is not a valid key.');
// express.raw keeps the body as the bytes we signed.
app.post('/hooks/seonis', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.get('webhook-id') ?? '';
const timestamp = req.get('webhook-timestamp') ?? '';
const header = req.get('webhook-signature') ?? '';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(401).send('Too old.');
}
const expected = crypto.createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(req.body)
.digest();
const valid = header.split(' ').some((signature) => {
const [version, value = ''] = signature.split(',');
const given = Buffer.from(value, 'base64');
return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!valid) return res.status(401).send('Bad signature.');
const delivery = JSON.parse(req.body);
res.json({ ok: true });
});
Or use a library
The official Standard Webhooks libraries, for PHP, JavaScript, Python, Go, Ruby and more, do all of this in one call. Give them the signing key as it was shown to you, whsec_ included.
github.com/standard-webhooks
The older headers
Deliveries also carry the signing secret as a bearer token, and X-AutoSEO-LT-Signature, an HMAC of the body alone made with that secret. They are still sent so that endpoints built on them keep working. A new endpoint should check webhook-signature instead: the old signature covers no time and no id, and its key travels in every request.
The AutoSEO, Outrank and BabyLoveGrowth formats send exactly what those services sent, so they do not carry these headers.