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

  1. 1Sign the raw body exactly as received; parsing and re-serializing JSON changes it.
  2. 2Compare in constant time and reject timestamps older than 5 minutes.
  3. 3Rotating the secret on the app page stops the old one at once.

Are you sure?

Keyboard shortcuts

⌘ KSearch and navigate⌘ JOpen and close the Workbenchg hHomeg pPaymentsg cCustomersg sSubscriptionsg aAppsg gGateways?This listEscClose