Verifying webhooks
The gateway POSTs JSON to your app’s webhook_url with two headers:
| Header | Value |
|---|---|
X-PAY-Timestamp | Unix epoch seconds at delivery time |
X-PAY-Signature | Lowercase hex HMAC-SHA256(webhook_secret, "<ts>.<rawBody>") |
The canonical string is <ts>.<rawBody> — much simpler than the request signing scheme. Use the webhook_secret you got at app creation time, NOT the request secret.
Critical rules
Section titled “Critical rules”- Verify against the raw bytes you received, not a re-serialized version. Body parsing middleware that re-stringifies JSON will silently break verification — both sides need to see byte-identical bodies.
- Use a constant-time comparison to prevent timing attacks on the signature. Don’t
if (a === b)— use the language’s secure compare. - Reject on missing headers or empty body — those should never happen in practice; if they do, something’s broken.
- Be idempotent. The gateway can deliver the same event twice; your handler must dedupe semantically (on
payment_id+event).
Node / Express
Section titled “Node / Express”import express from 'express';import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
// IMPORTANT: get raw body, not parsed JSON.app.post('/api/payment-webhook', express.raw({ type: 'application/json' }), (req, res) => { const ts = req.headers['x-pay-timestamp']; const sig = req.headers['x-pay-signature']; const rawBody = req.body.toString();
if (!ts || !sig) return res.status(401).send('missing headers');
const expected = createHmac('sha256', process.env.PAYMENT_WEBHOOK_SECRET) .update(`${ts}.${rawBody}`) .digest('hex');
if (expected.length !== sig.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) { return res.status(401).send('bad signature'); }
const event = JSON.parse(rawBody); handleEvent(event); res.status(200).send('ok'); });Laravel / PHP
Section titled “Laravel / PHP”Route::post('/payment-webhook', [PaymentWebhookController::class, 'handle']);
// app/Http/Controllers/PaymentWebhookController.phppublic function handle(Request $request){ $ts = $request->header('X-PAY-Timestamp'); $sig = $request->header('X-PAY-Signature'); $rawBody = $request->getContent();
if (!$ts || !$sig) { abort(401, 'missing headers'); }
$expected = hash_hmac('sha256', "$ts.$rawBody", config('services.payment.webhook_secret'));
if (!hash_equals($expected, $sig)) { abort(401, 'bad signature'); }
$event = json_decode($rawBody, true); dispatch(new ProcessPaymentEvent($event));
return response('ok', 200);}Go / chi or net/http
Section titled “Go / chi or net/http”package webhook
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os")
func Handle(w http.ResponseWriter, r *http.Request) { ts := r.Header.Get("X-PAY-Timestamp") sig := r.Header.Get("X-PAY-Signature") if ts == "" || sig == "" { http.Error(w, "missing headers", http.StatusUnauthorized) return }
rawBody, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "body read", http.StatusBadRequest) return }
secret := os.Getenv("PAYMENT_WEBHOOK_SECRET") mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + "." + string(rawBody))) expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sig)) { http.Error(w, "bad signature", http.StatusUnauthorized) return }
handleEvent(rawBody) w.WriteHeader(http.StatusOK)}Rust / axum
Section titled “Rust / axum”use axum::{ body::Bytes, extract::State, http::{HeaderMap, StatusCode},};use hmac::{Hmac, Mac};use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub async fn handle( State(secret): State<String>, headers: HeaderMap, body: Bytes,) -> Result<&'static str, StatusCode> { let ts = headers.get("x-pay-timestamp") .and_then(|v| v.to_str().ok()) .ok_or(StatusCode::UNAUTHORIZED)?; let sig = headers.get("x-pay-signature") .and_then(|v| v.to_str().ok()) .ok_or(StatusCode::UNAUTHORIZED)?;
let canonical = format!("{}.{}", ts, std::str::from_utf8(&body).unwrap_or("")); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; mac.update(canonical.as_bytes()); let expected = hex::encode(mac.finalize().into_bytes());
if !constant_time_eq::constant_time_eq(expected.as_bytes(), sig.as_bytes()) { return Err(StatusCode::UNAUTHORIZED); }
handle_event(&body).await; Ok("ok")}Common mistakes
Section titled “Common mistakes”| Symptom | Cause |
|---|---|
| Verification fails on every event | Body got re-serialized. Use raw-body middleware (express.raw, Laravel’s getContent(), etc.). |
| Verification works locally, fails in prod | Some proxies (Cloudflare, certain nginx configs) tweak whitespace or encoding. Test with the exact bytes that hit your handler. |
| Verification succeeds but event seems wrong | You’re verifying with the WRONG secret — likely the request secret, not the webhook_secret. They’re different. |
| Header lookup returns empty | Some frameworks lowercase headers. The gateway sends X-PAY-Timestamp / X-PAY-Signature exactly; your framework may serve them as x-pay-timestamp etc. when reading. Both work; just match one consistently. |
Replay protection
Section titled “Replay protection”The gateway includes the timestamp in the signed string, so a replayed delivery would have to match BOTH the signature AND happen within whatever window your handler enforces.
You can additionally check |now - ts| < 5 minutes to reject obviously old replays:
const now = Math.floor(Date.now() / 1000);if (Math.abs(now - parseInt(ts, 10)) > 300) { return res.status(401).send('timestamp out of range');}This protects against a recorded webhook being replayed against you days later. For most products it’s overkill; consider it if your handler does anything irreversible.