Skip to main content
Webhooks are the moments where external trust meets our money-moving code. A forged webhook that bypassed verification could fake a payment. None of them do. This page is the per-source verification spec.

The verification stack

Three checks. Each rejects independently. A failure at any layer means the operation does not proceed.

OPay — three signature schemes

OPay fires webhooks with different signature header conventions depending on the event type. Our middleware accepts all three.

Scheme 1: x-opay-encrypted-body (full body)

Header present: x-opay-encrypted-body: <hex_signature>. Verification:

Scheme 2: x-opay-encrypted-body (DVA field subset)

Same header. But for dynamic-virtual-account events, the signature is computed over a three-field JSON subset:
We try the full-body scheme first; if it fails, we fall back to the DVA subset.

Scheme 3: x-opay-signature (v2/v3)

A different header, used for newer event types. Two sub-modes:

The middleware logic

The reconcile guard

After signature verification passes, every credit-bearing event triggers a second check: re-verification against OPay’s authoritative transaction-verify endpoint.
A forged webhook with a stolen secret would pass the HMAC check. It would fail the reconcile check because OPay’s own database would not show a successful transaction with that reference.

Meta WhatsApp Cloud

Meta uses X-Hub-Signature-256, the standard HMAC-SHA256 webhook signature shape used across Facebook/Instagram/WhatsApp.
The Meta webhook also requires hub-verification during initial setup: a GET /webhook request from Meta with hub.mode=subscribe, hub.verify_token=<our_token>, hub.challenge=<random>. We echo back hub.challenge to complete setup. After setup, only POST /webhook traffic flows.

Voice runtime webhooks

Our voice runtime uses a shared-secret header:
Simpler than HMAC because the body shape varies and pre-shared secret comparison is sufficient for our trust relationship with the runtime. Timing-safe compare is still used.

SMS gateway webhooks

Our Go SMS gateway signs inbound webhooks (replies) with a shared secret over the raw body:

Partner API outbound webhooks

When Guild fires a webhook to a partner’s endpoint (consent.granted, standing.tier_changed, etc.), we sign the outbound with the partner’s per-subscription secret. Each partner verifies on receipt with the same shape.
We include the timestamp in the signed material to support replay protection on the partner’s side. Partners are expected to reject events with timestamps more than 5 minutes off from their server time. Full partner-side verification spec: Partner webhooks.

Verifying in your language

Copy-paste-ready reference implementations of the partner-side signature check. Same logic across all five:
  1. Pull the X-Guild-Signature header (t=<unix_seconds>,v1=<hex_hmac>).
  2. Reject if the timestamp drifts more than 300 seconds from your server clock.
  3. Compute HMAC-SHA256 of {timestamp}.{raw_body} with your webhook secret.
  4. Compare against v1 in constant time.
  5. Only process the event if all four pass.
More language samples on the way — these five (JavaScript, Python, PHP, C#, Java) cover most partner stacks today.
Three things every reference implementation gets right that hand-rolled code often misses:
  1. Use the raw body, not a re-serialised JSON string. If you parse the body and then JSON.stringify it again, the bytes will differ from what we signed (whitespace, key order, etc.) and verification will fail. Most frameworks let you access the raw bytes — use them.
  2. Constant-time compare. Don’t use == or === for the signature check. Use your language’s hmac.compare_digest / timingSafeEqual / MessageDigest.isEqual / hash_equals / FixedTimeEquals. Linear-time string comparison leaks information about how close a wrong signature was to right.
  3. Reject stale timestamps. Replay protection is half of the security guarantee. Without the timestamp check, a captured webhook from a year ago could be replayed against your handler today.

Why raw body matters

nginx is configured with proxy_request_buffering off on every webhook route. If nginx buffered and re-emitted the body, the byte sequence could change (different whitespace, different line endings, etc.) and HMAC verification would fail. express.raw() middleware is used on webhook routes to expose the unmodified body bytes as req.rawBody. Standard express.json() parsing happens after signature verification, never before. This is one of those quiet correctness details that only matters when it doesn’t work. We learned this from a real bug; the fix is one line in nginx.conf.

Idempotency on inbound webhooks

After signature verification, every webhook is idempotency-checked on its source-provided event ID. Detail in OPay reference. A duplicate delivery is acknowledged with 200 but does nothing. OPay’s own retry on a network hiccup never causes double-processing.

OPay reference

The full OPay webhook event taxonomy and reconcile guard logic.

Partner webhooks

The outbound side — signing webhooks we deliver to partners.

Audit ledger

Where webhook events end up.