Verify signatures

{/* Generated by scripts/docs/generate-webhook-docs.ts from the verifiers that the signature vectors test. Do not edit. */}

Each request carries X-RankPilot-Signature: t=<unix>,v1=<hex>. The signed string is <t>.<raw request body>, the key is your endpoint secret (whsec_…) and the algorithm is HMAC-SHA256. While a secret is being rotated the header holds two v1 values; accept the request if either matches. Compare in constant time and reject a timestamp more than 300 seconds from your clock. Verify the raw body, before any JSON parsing.

Node

// Reference verifier for a webhook receiver (S22.10). Standalone on purpose: the docs embed this file as it is, so a reader
// can paste it without our packages. Header: `t=<unix>,v1=<hex>[,v1=<hex>]`; signed string: `${t}.${rawBody}`.
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verifyWebhook(
  secret: string,
  header: string,
  rawBody: string,
  now = Math.floor(Date.now() / 1000),
): boolean {
  const parts = header.split(',').map((p) => p.split('=') as [string, string | undefined]);
  const stamp = Number(parts.find(([k]) => k === 't')?.[1]);
  if (!Number.isFinite(stamp) || Math.abs(now - stamp) > TOLERANCE_SECONDS) return false;
  const expected = createHmac('sha256', secret).update(`${stamp}.${rawBody}`).digest();
  return parts
    .filter(([k]) => k === 'v1')
    .some(([, v]) => {
      if (v === undefined || !/^[0-9a-f]{64}$/i.test(v)) return false;
      return timingSafeEqual(Buffer.from(v, 'hex'), expected);
    });
}

Python

# Reference verifier for a webhook receiver (S22.10). Header: t=<unix>,v1=<hex>[,v1=<hex>]; signed string: "<t>.<rawBody>".
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300


def verify_webhook(secret: str, header: str, raw_body: str, now=None) -> bool:
    now = int(time.time()) if now is None else now
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    stamps = [v for k, v in parts if k == "t"]
    if not stamps or not re.fullmatch(r"\d+", stamps[0]):
        return False
    t = int(stamps[0])
    if abs(now - t) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return any(
        re.fullmatch(r"[0-9a-fA-F]{64}", v) and hmac.compare_digest(v.lower(), expected)
        for k, v in parts
        if k == "v1"
    )

PHP

<?php
// Reference verifier for a webhook receiver (S22.10). Header: t=<unix>,v1=<hex>[,v1=<hex>]; signed string: "<t>.<rawBody>".
const TOLERANCE_SECONDS = 300;

function verify_webhook(string $secret, string $header, string $rawBody, ?int $now = null): bool
{
    $now ??= time();
    $stamp = null;
    $candidates = [];
    foreach (explode(',', $header) as $part) {
        $pair = explode('=', $part, 2);
        if (count($pair) !== 2) {
            continue;
        }
        if ($pair[0] === 't') {
            $stamp ??= $pair[1];
        } elseif ($pair[0] === 'v1') {
            $candidates[] = $pair[1];
        }
    }
    if ($stamp === null || !ctype_digit($stamp) || abs($now - (int) $stamp) > TOLERANCE_SECONDS) {
        return false;
    }
    $expected = hash_hmac('sha256', $stamp . '.' . $rawBody, $secret);
    foreach ($candidates as $candidate) {
        if (preg_match('/^[0-9a-fA-F]{64}$/', $candidate) === 1 && hash_equals($expected, strtolower($candidate))) {
            return true;
        }
    }
    return false;
}