Skip to content

Verify webhook signature

Every webhook call is signed with the secret of its webhook, so your endpoint can check that the call comes from Interankiety and was not changed on the way.

You get the secret in the secret field when you create or update the webhook. Calling PUT again on an existing webhook returns the same secret, so you can get it back if you lose it. GET endpoints never return it.

Webhook-Signature: t=1760954400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t - time the call was sent, as a Unix timestamp (seconds)
  • v1 - HMAC-SHA256 of the text {t}.{body}, keyed with your secret, as lowercase hex. {body} is the raw request body, exactly as received.
  1. Read the raw request body before parsing the JSON. Parsing and encoding it again can change the bytes.
  2. Split the header into t and v1.
  3. Calculate HMAC-SHA256(secret, t + "." + body) and compare it with v1 using a constant-time comparison.
  4. Reject calls where t is more than 5 minutes away from your server’s time, so a recorded call cannot be sent again later.

Answer with a 2xx code only once the signature is valid.

$body = file_get_contents('php://input');
$secret = getenv('WEBHOOK_SECRET'); // whsec_...
parse_str(str_replace(',', '&', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? ''), $signature);
$timestamp = (int) ($signature['t'] ?? 0);
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
if (! hash_equals($expected, $signature['v1'] ?? '') || abs(time() - $timestamp) > 300) {
http_response_code(401);
exit;
}
$payload = json_decode($body, true);
import crypto from 'node:crypto';
// rawBody: the request body as a Buffer, e.g. express.raw({ type: 'application/json' })
function isValidWebhook(rawBody, header, secret) {
const parts = Object.fromEntries((header ?? '').split(',').map((part) => part.split('=')));
const timestamp = Number(parts.t);
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
return typeof parts.v1 === 'string'
&& parts.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - timestamp) <= 300;
}