SurfacedBySurfacedBy Docs

Webhook Signatures

Verify HMAC-SHA256 signatures on incoming webhook deliveries.

Ask an AI:Open in ChatGPTOpen in Claude

Every outbound webhook delivery is signed with HMAC-SHA256. Your handler must verify the signature before trusting the payload. Without verification, any attacker who knows your endpoint URL could POST arbitrary JSON to it.

Headers

SurfacedBy sets two headers on every delivery:

  • X-SurfacedBy-Timestamp - Unix epoch seconds at signing time.
  • X-SurfacedBy-Signature - t=<timestamp>,v1=<hex-hmac-sha256>.

The signature is computed over the concatenation of the timestamp, a single dot, and the raw request body. Using the raw bytes (not a re-serialised JSON object) matters: any whitespace difference invalidates the signature.

Verification algorithm

  1. Read the X-SurfacedBy-Signature header and parse out the v1= segment.
  2. Read the X-SurfacedBy-Timestamp header.
  3. Reject the request if the timestamp is older than your tolerance window (5 minutes is a sensible default). This prevents replay attacks.
  4. Compute HMAC-SHA256(secret, timestamp + "." + raw_body) and hex-encode it.
  5. Compare the computed value to the v1= segment using a constant-time comparison.

If any step fails, return 400 or 401 and do not process the payload.

Python

import hmac
import hashlib
import time
 
def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    sig_header = headers.get("X-SurfacedBy-Signature", "")
    ts = headers.get("X-SurfacedBy-Timestamp", "")
    if not sig_header or not ts:
        return False
 
    parts = dict(kv.split("=", 1) for kv in sig_header.split(","))
    provided = parts.get("v1")
    if not provided:
        return False
 
    if abs(time.time() - int(ts)) > 300:
        return False
 
    payload = f"{ts}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)

Node.js

const crypto = require("crypto");
 
function verifyWebhook(secret, headers, rawBody) {
  const sigHeader = headers["x-surfacedby-signature"] || "";
  const ts = headers["x-surfacedby-timestamp"] || "";
  if (!sigHeader || !ts) return false;
 
  const parts = Object.fromEntries(
    sigHeader.split(",").map((kv) => kv.split("=", 2))
  );
  const provided = parts.v1;
  if (!provided) return false;
 
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(ts, 10)) > 300) return false;
 
  const payload = Buffer.concat([Buffer.from(`${ts}.`), rawBody]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(payload)
    .digest("hex");
 
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(provided, "hex");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

PHP

<?php
function verify_webhook(string $secret, array $headers, string $rawBody): bool {
    $sigHeader = $headers['X-SurfacedBy-Signature'] ?? '';
    $ts = $headers['X-SurfacedBy-Timestamp'] ?? '';
    if ($sigHeader === '' || $ts === '') return false;
 
    $parts = [];
    foreach (explode(',', $sigHeader) as $kv) {
        [$k, $v] = array_pad(explode('=', $kv, 2), 2, null);
        $parts[$k] = $v;
    }
    $provided = $parts['v1'] ?? null;
    if ($provided === null) return false;
 
    if (abs(time() - (int)$ts) > 300) return false;
 
    $payload = $ts . '.' . $rawBody;
    $expected = hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $provided);
}

Storing the secret

Signing secrets are shown once when you register an endpoint and are not recoverable afterwards. Store them in a secret manager. Rotate by registering a new endpoint with a fresh secret, cutting traffic over, then deleting the old endpoint.

On this page