Skip to main content
Every delivery is signed so you can confirm it genuinely came from Audienceful and wasn’t tampered with or replayed. Verify the signature on each incoming request before trusting its contents.

The signature header

Each delivery carries an X-Audienceful-Signature header containing a timestamp and a signature:
X-Audienceful-Signature: t=1751630400,v1=<hex hmac-sha256>
  • t — the Unix timestamp (seconds) when the delivery was signed.
  • v1 — the signature: the lowercase hex HMAC-SHA256 of the string {t}.{raw_request_body}, keyed with your endpoint’s signing secret.

How to verify

  1. Read the raw request body and the X-Audienceful-Signature header.
  2. Parse t and v1 out of the header.
  3. Reject the request if t is more than 5 minutes away from your current time (replay protection).
  4. Compute HMAC-SHA256(secret, "{t}.{raw_body}") as lowercase hex.
  5. Compare your computed value against v1 using a constant-time comparison. If they match, the request is authentic.
Your signing secret (whsec_…) is returned once, when you create the endpoint.
Verify against the raw request body bytes, exactly as received — before any JSON parsing or re-serialization. Re-serializing the body (even reformatting whitespace) changes it, and the signature will no longer match. Most frameworks expose the raw body separately from the parsed JSON.

Examples

import hashlib, hmac, time


def verify_signature(secret: str, raw_body: str, signature_header: str, tolerance: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
    timestamp = int(parts["t"])

    # Reject stale timestamps (replay protection).
    if abs(time.time() - timestamp) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
import crypto from "crypto";

export function verifySignature(
  secret: string,
  rawBody: string,
  signatureHeader: string,
  toleranceSeconds = 300,
): boolean {
  const parts: Record<string, string> = {};
  for (const kv of signatureHeader.split(",")) {
    const [key, value] = kv.split("=", 2);
    parts[key] = value;
  }

  const timestamp = parseInt(parts.t, 10);
  // Reject stale timestamps (replay protection).
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const expectedBuf = Buffer.from(expected);
  const providedBuf = Buffer.from(parts.v1 ?? "");
  return (
    expectedBuf.length === providedBuf.length &&
    crypto.timingSafeEqual(expectedBuf, providedBuf)
  );
}
<?php

function verify_signature(string $secret, string $rawBody, string $signatureHeader, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $kv) {
        [$key, $value] = array_pad(explode('=', $kv, 2), 2, '');
        $parts[$key] = $value;
    }

    $timestamp = (int) ($parts['t'] ?? 0);
    // Reject stale timestamps (replay protection).
    if (abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}
The same signature scheme is used by the automation “Send Webhook” action, signed with that action’s own secret — so the same verification code works there too.