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-Signaturesha256= followed by the hex signature

Verify

The signature is an HMAC-SHA256 of the raw request body, keyed with your webhook's signing secret: the body and nothing else. Compute it over the bytes you received, before any JSON parsing, and compare with a timing-safe comparison.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret) {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(header ?? '');
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
function verify(string $rawBody, ?string $header, string $secret): bool
{
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return $header !== null && hash_equals($expected, $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 scheme has no timestamp component; the signature covers the body alone. Replay of an identical body therefore verifies, which is another reason to deduplicate on X-FoPost-Delivery and treat your handler as idempotent.

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