Skip to main content
Anton signs every webhook delivery with an HMAC-SHA256 signature derived from your subscription’s signing secret. Verifying the signature on your endpoint is the only way to prove a request really came from Anton and not an attacker who discovered your URL.
Treat an unverified webhook the same as an untrusted HTTP request. Do not act on the payload — do not mark payouts as settled, do not release funds, do not update beneficiaries — until the signature has been verified successfully.

The signature scheme

PropertyValue
AlgorithmHMAC-SHA256
EncodingLowercase hex
Signed string{timestamp}.{body} — Unix timestamp, a literal dot, and the raw request body
Header carrying the signatureX-Webhook-Signature, prefixed with v1=
Header carrying the timestampX-Webhook-Timestamp (Unix seconds, UTC)
Secret formatwhsec_ followed by 64 hex characters
Every delivery also carries X-Webhook-ID (the event id, useful for deduplication) and X-Webhook-Event (the event type). The User-Agent is AntonPayments-Webhook/1.0.

Verification steps

1

Read the raw request body

Capture the body as bytes before any JSON parsing or framework normalisation. Parsed-and-re-serialised JSON will not byte-match the string Anton signed.
2

Read the timestamp and signature headers

Pull X-Webhook-Timestamp and X-Webhook-Signature from the request. Strip the v1= prefix on the signature.
3

Reject old timestamps

If the timestamp is more than 5 minutes (300 seconds) away from your server’s current time, reject the request. This prevents replays of captured deliveries.
4

Compute the expected signature

Concatenate {timestamp}.{body}, compute HMAC-SHA256 over it using your subscription’s signing secret, and hex-encode the result.
5

Compare in constant time

Compare the expected signature to the header value using a constant-time comparison (hmac.compare_digest, crypto.timingSafeEqual, hash_equals, hmac.Equal). Never use == — it leaks timing information.
6

Respond 2xx within 30 seconds

Return any 2xx status to acknowledge receipt. Non-2xx responses are retried with exponential backoff up to 5 total attempts.

Verification code

All examples read the raw body, enforce a 5-minute freshness window, and use a constant-time comparison.
#!/usr/bin/env bash
# Example: verify a webhook from the command line. In production, do this in
# your application code — not in a shell script.

SECRET="$WEBHOOK_SECRET"        # whsec_...
TIMESTAMP="$1"                  # value of X-Webhook-Timestamp
SIGNATURE="${2#v1=}"            # value of X-Webhook-Signature with v1= stripped
BODY="$(cat)"                   # raw body piped on stdin

NOW=$(date +%s)
AGE=$(( NOW - TIMESTAMP ))
if [ "$AGE" -gt 300 ] || [ "$AGE" -lt -300 ]; then
  echo "reject: stale timestamp ($AGE seconds)" >&2
  exit 1
fi

EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | awk '{print $2}')

# openssl does not provide a constant-time compare, so we fall back to
# a length check plus a fixed-cost comparison in a higher-level language
# when this matters. For non-production shells this is acceptable:
if [ "$EXPECTED" = "$SIGNATURE" ]; then
  echo "ok"
else
  echo "reject: signature mismatch" >&2
  exit 1
fi

Rotating secrets

Every subscription has one signing secret. The secret is returned once — in the response to POST /v1/webhooks — and never again. Store it in your secrets manager at creation time.
curl https://api.antonpayments.dev/v1/webhooks/whk_01HX.../secret \
  -H "Authorization: Bearer ak_test_..."
Response:
{ "data": { "secret": "whsec_..." } }
curl https://api.antonpayments.dev/v1/webhooks/whk_01HX.../secret/rotate \
  -X POST \
  -H "Authorization: Bearer ak_test_..."
Response:
{ "data": { "secret": "whsec_..." } }
Rotation replaces the secret immediately. Any deliveries already in flight at the moment of rotation will be signed with the new secret as of the next attempt.
Rotation is immediate — there is no overlap window during which both the old and new secret are accepted. Roll the new secret into your endpoint’s configuration before calling rotate, or be prepared for a brief window where in-flight deliveries fail verification and retry.

Common pitfalls

  • Parsed JSON instead of raw bytes. Frameworks that parse and re-serialise the body (Express’s default json() middleware, some ASP.NET pipelines) will break verification — whitespace, key ordering, and number formatting all change. Capture the raw body before parsing.
  • Wrong header case. HTTP headers are case-insensitive, but some frameworks expose them under specific casing. Read X-Webhook-Signature / X-Webhook-Timestamp via your framework’s header API, not by exact-match dictionary lookup.
  • Forgetting to strip v1=. The signature header is v1={hex}. Strip the prefix before hex-decoding.
  • Trailing newlines or encoding changes. A proxy or middleware that appends \n or transcodes the body will invalidate the signature.
  • Using == or strcmp. Byte-by-byte equality checks leak timing information. Always use a constant-time comparison.
  • Not enforcing the timestamp window. Without a freshness check, an attacker who captures one valid delivery can replay it indefinitely. Reject anything older than 5 minutes.
  • Replying slowly. Deliveries time out after 30 seconds. Acknowledge first, process asynchronously.