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:
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.Meta WhatsApp Cloud
Meta usesX-Hub-Signature-256, the standard HMAC-SHA256 webhook signature shape used across Facebook/Instagram/WhatsApp.
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: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.Verifying in your language
Copy-paste-ready reference implementations of the partner-side signature check. Same logic across all five:- Pull the
X-Guild-Signatureheader (t=<unix_seconds>,v1=<hex_hmac>). - Reject if the timestamp drifts more than 300 seconds from your server clock.
- Compute
HMAC-SHA256of{timestamp}.{raw_body}with your webhook secret. - Compare against
v1in constant time. - Only process the event if all four pass.
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.What to read next
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.