Verify the signature
Every delivery is signed with the endpoint's signing secret (pwhsec_…), the way Stripe signs its own.
The header
Headers
X-Pay-Signature: t=<unix time>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")> X-Pay-Event-Id: <uuid, the same on a resend>
Verify it
Ruby
t, v1 = request.headers["X-Pay-Signature"].scan(/t=(\d+),v1=(\h+)/).first body = request.raw_post expected = OpenSSL::HMAC.hexdigest("SHA256", ENV["PAY_WEBHOOK_SECRET"], "#{t}.#{body}") head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, v1.to_s) head :unauthorized if (Time.now.to_i - t.to_i).abs > 300
Node
import crypto from "node:crypto" const [, t, v1] = req.get("X-Pay-Signature").match(/t=(\d+),v1=([0-9a-f]+)/) const expected = crypto.createHmac("sha256", process.env.PAY_WEBHOOK_SECRET) .update(`${t}.${rawBody}`).digest("hex") const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - Number(t)) <= 300
Keep in mind
- 1Sign the raw body exactly as received; parsing and re-serializing JSON changes it.
- 2Compare in constant time and reject timestamps older than 5 minutes.
- 3Rotating the secret on the app page stops the old one at once.