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 using the endpoint's signing secret. Verify the signature before trusting the payload. Without verification, anyone who knows your endpoint URL could POST arbitrary JSON to it.

Headers

SurfacedBy sets two signature headers on every delivery:

  • X-SurfacedBy-Timestamp - Unix epoch seconds at signing time.
  • X-SurfacedBy-Signature - t=<timestamp>,v1=<hex-hmac-sha256>. The t value is the same timestamp as the header above.

The signature is computed over the timestamp, a single dot, and the raw request body: HMAC-SHA256(secret, timestamp + "." + raw_body), hex-encoded in lowercase. The key is the signing secret exactly as shown when you created the endpoint (a 64-character hex string), used as text. Do not hex-decode it.

Compute the HMAC over the raw bytes you received, not over JSON you parsed and re-serialised; any difference in whitespace or key order invalidates the signature.

Each delivery attempt is signed when it is sent, so a retry or a replay carries a fresh timestamp and signature.

Verification algorithm

  1. Read the X-SurfacedBy-Signature header and parse out the v1= value.
  2. Read the X-SurfacedBy-Timestamp header.
  3. Reject the request if the timestamp is further from your server's clock than your tolerance window. SurfacedBy does not impose a window; 5 minutes is a sensible default. This limits replay of a captured request.
  4. Compute HMAC-SHA256(secret, timestamp + "." + raw_body) and hex-encode it.
  5. Compare the computed value to the v1= value with a constant-time comparison.

If any step fails, return 400 or 401 and do not process the payload. A non-2xx response counts as a failed attempt and is retried, so fix verification problems before they exhaust the retries.

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(",") if "=" in kv)
    provided = parts.get("v1")
    if not provided:
        return False
 
    try:
        if abs(time.time() - int(ts)) > 300:
            return False
    except ValueError:
        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)

Pass the undecoded request body, for example request.get_data() in Flask or await request.body() in FastAPI or Starlette. Header lookups must be case-insensitive in your framework, or normalise the names first.

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);
}

rawBody must be a Buffer of the unparsed body, for example from express.raw({ type: "application/json" }) on the webhook route. Node lowercases incoming header names.

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);
}

Read the body with file_get_contents('php://input'). Header name casing depends on your server; normalise the keys of $headers if needed.

Storing the secret

The signing secret is shown once, when you create the endpoint, and cannot be retrieved later. Store it in a secret manager. There is no rotate action. To change a secret, create a new endpoint with the same URL and events, have your receiver accept a signature from either secret, delete the old endpoint, then drop the old secret. While both endpoints exist, each event is delivered twice with the same event id, so deduplicate on it.

On this page