Verifying Signatures

Prove a delivery came from FoPost before acting on it.

Every delivery is signed with the secret returned when the webhook was created. An endpoint that skips verification will publish whatever anyone who guesses the URL sends it, so reject anything that does not verify.

The headers

HeaderContents
Content-Typeapplication/json
X-FoPost-EventThe event name
X-FoPost-DeliveryThe delivery id, stable across retries of the same event, so it is your deduplication key
X-FoPost-TimestampUnix seconds, when this attempt was sent
X-FoPost-Signature-256sha256= followed by the hex HMAC-SHA256 of <timestamp>.<rawBody>
X-FoPost-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body alone. The legacy signature

Verify

Both signatures are HMAC-SHA256 keyed with your webhook's signing secret, computed over the raw bytes you received, before any JSON parsing. Check the timestamped one: it is what stops a captured delivery from being replayed later.

  1. Read the body as raw bytes
  2. Reject when X-FoPost-Timestamp is more than 5 minutes (300 seconds) from now
  3. Compute HMAC-SHA256 over <timestamp>.<rawBody> and compare it to X-FoPost-Signature-256 with a timing-safe comparison
  4. Only then parse the JSON
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

function verify(rawBody, timestamp, header, secret) {
  if (!timestamp || !header) return false;
  const sent = Number(timestamp);
  if (!Number.isFinite(sent) || Math.abs(Date.now() / 1000 - sent) > TOLERANCE_SECONDS) return false;
  const expected =
    'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(header.trim());
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
function verify(string $rawBody, ?string $timestamp, ?string $header, string $secret): bool
{
    if ($timestamp === null || $header === null) {
        return false;
    }
    if (!is_numeric($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, trim($header));
}

Three mistakes account for nearly every "signature never matches" report:

  • Verifying a re-serialised body. Frameworks that parse JSON before your handler runs hand you an object; JSON.stringify of that object is not guaranteed to be byte-identical to what was sent. Verify the raw bytes
  • Comparing with ==. String comparison leaks timing; use your language's constant-time comparison
  • Dropping the sha256= prefix on one side only. Compare like with like

The legacy signature

X-FoPost-Signature is the HMAC-SHA256 of the raw body alone, with no timestamp. It is still sent on every delivery so older receivers keep working, and a redelivery carries the original value because the body is identical. On its own it does not protect against replay, so a receiver that only checks it has to deduplicate on X-FoPost-Delivery and stay idempotent. Fall back to it only when no X-FoPost-Timestamp header is present.

Several framework integrations verify for you: the Symfony, Rails, Django, and FastAPI packages each ship a receiver that checks the signature and dispatches a native event.

Next

Related documentation
  • Webhooks

    Hear about publishing outcomes and account health without polling.

  • Events

    Every webhook event FoPost can send, when it fires, and its payload.

  • Retries and Failures

    Timeouts, retry backoff, auto-disable, and test deliveries.

  • Symfony

    The FoPost PHP client as a Symfony bundle, with autowiring, console commands, and webhook events.

  • Rails

    The FoPost Ruby client wired into Rails, with credentials, ActiveJob jobs, and a webhook engine.

  • Django

    The FoPost Python client wired into Django, with settings, management commands, and signals.

  • FastAPI

    The FoPost Python client wired into FastAPI, with settings, dependency injection, and a webhook router.

Was this helpful?

On this page