Skip to content

Verifying webhooks

The gateway POSTs JSON to your app’s webhook_url with two headers:

HeaderValue
X-PAY-TimestampUnix epoch seconds at delivery time
X-PAY-SignatureLowercase 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.

  1. 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.
  2. Use a constant-time comparison to prevent timing attacks on the signature. Don’t if (a === b) — use the language’s secure compare.
  3. Reject on missing headers or empty body — those should never happen in practice; if they do, something’s broken.
  4. Be idempotent. The gateway can deliver the same event twice; your handler must dedupe semantically (on payment_id + event).
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');
});
routes/api.php
Route::post('/payment-webhook', [PaymentWebhookController::class, 'handle']);
// app/Http/Controllers/PaymentWebhookController.php
public 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);
}
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)
}
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")
}
SymptomCause
Verification fails on every eventBody got re-serialized. Use raw-body middleware (express.raw, Laravel’s getContent(), etc.).
Verification works locally, fails in prodSome proxies (Cloudflare, certain nginx configs) tweak whitespace or encoding. Test with the exact bytes that hit your handler.
Verification succeeds but event seems wrongYou’re verifying with the WRONG secret — likely the request secret, not the webhook_secret. They’re different.
Header lookup returns emptySome 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.

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.