This page documents the verification BetterSuite performs on inbound provider webhooks. There is no BetterSuite-issued outbound webhook signature today — no BetterSuite-Signature header exists, no platform-issued webhook secret is rotated. If you need to react to platform events from your own code, see the "Subscribing to events" section in Payment Webhooks for what's actually wired.
What follows is useful in two cases:
- You're debugging why a real provider call into BetterSuite was rejected.
- You're standing up your own listener directly at the PSP or KYC provider — the same scheme the platform implements will work for you too.
Stripe (payment webhooks)
Stripe signs every webhook with the scheme described in the Stripe webhook docs. BetterSuite delegates to the async-stripe SDK's Webhook::construct_event to do the math.
What we check
StripeIntegration::verify_webhook (backend/crates/psp-plugins/stripe/src/stripe_integration.rs:482-521):
- Loads the per-PSP-account signing secret (decrypted from
psp_accounts.encrypted_webhook_secret). - Pulls the
Stripe-Signaturerequest header (case-insensitive match). - Calls
Webhook::construct_event(body, signature, secret)— that handles thet=…,v1=…parsing, the HMAC-SHA256 comparison, and the 5-minute default timestamp tolerance from the Stripe SDK. - Returns
Ok(true)on a clean verification,Ok(false)onBadSignature, andErron anything else.
If verification returns false, ProcessWebhookUseCase (payment/application/src/webhooks/process_webhook_usecase.rs:97-101) maps that to a 401-class error and the handler responds non-2xx, so Stripe will retry.
Doing the same in your code
Use Stripe's official SDK for your language — they all bundle the verifier. The header is Stripe-Signature, the secret is your endpoint's whsec_…. Example for Node.js using the official stripe library:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function verifyStripeWebhook(rawBody, header, secret) {
// throws if the signature doesn't match or the timestamp is outside tolerance
return stripe.webhooks.constructEvent(rawBody, header, secret);
}
Two things that bite people every time:
- Use the raw request body. Anything that re-serializes JSON (a single whitespace change, a key reordering) breaks the HMAC. In Express, register
express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })and passreq.rawBodyto the verifier. - Constant-time comparison only. The Stripe SDK does this for you; if you're re-implementing the math, use
crypto.timingSafeEqual(Node),hmac.compare_digest(Python), orhmac.Equal(Go) — never===.
Sumsub (KYC webhooks)
Sumsub signs webhooks with HMAC-SHA256 in X-Payload-Digest. BetterSuite verifies it directly without an SDK.
What we check
SumsubKycProvider::verify_webhook_signature (backend/crates/kyc/infra/src/providers/sumsub_kyc_provider.rs:222-254):
- Loads the per-config
webhook_secret. - Strips an optional
sha256-hmac.prefix from the header value (Sumsub's format note describes this assha256-hmac:<signature>; the implementation accepts both the prefixed and bare form). - Computes
HMAC-SHA256(raw_body)keyed by the secret, hex-encodes it, compares with the header value.
Unlike Stripe, there is no timestamp envelope on Sumsub webhooks — replay protection is the provider's responsibility (and yours, if you're consuming directly).
Doing the same in your code
import crypto from "crypto";
export function verifySumsubWebhook(rawBody, header, secret) {
const provided = header.startsWith("sha256-hmac.")
? header.slice("sha256-hmac.".length)
: header;
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
if (
expected.length !== provided.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
) {
throw new Error("invalid signature");
}
}
Same caveats: use the raw body, compare in constant time.
Other providers
The signature header names for Onfido (X-SHA2-Signature) and a generic fallback (X-Signature) are mapped in the KYC webhook handler (kyc/application/src/handle_kyc_webhook/webhook_handler.rs:80-94), but the verification routines themselves aren't implemented yet — the corresponding KycProviderPort impls return an error from the factory. When those land, this page will document the exact scheme.
There is no MercadoPago signature verifier today either — the MercadoPago plugin's verify_webhook is a stub that returns PspError::Unsupported (backend/crates/psp-plugins/mercadopago/src/impls.rs:45-51).
What's next
- Payment Webhooks — the events Stripe sends and what BetterSuite does with each one.
- KYC Webhooks — same for Sumsub, plus a note on which providers aren't wired yet.