Signature Verification

How BetterSuite verifies inbound webhook signatures from Stripe and Sumsub — and what to do when integrating against the same providers from your own code.

Last updated May 19, 2026

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:

  1. You're debugging why a real provider call into BetterSuite was rejected.
  2. 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):

  1. Loads the per-PSP-account signing secret (decrypted from psp_accounts.encrypted_webhook_secret).
  2. Pulls the Stripe-Signature request header (case-insensitive match).
  3. Calls Webhook::construct_event(body, signature, secret) — that handles the t=…,v1=… parsing, the HMAC-SHA256 comparison, and the 5-minute default timestamp tolerance from the Stripe SDK.
  4. Returns Ok(true) on a clean verification, Ok(false) on BadSignature, and Err on 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:

  1. 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 pass req.rawBody to the verifier.
  2. 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), or hmac.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):

  1. Loads the per-config webhook_secret.
  2. Strips an optional sha256-hmac. prefix from the header value (Sumsub's format note describes this as sha256-hmac:<signature>; the implementation accepts both the prefixed and bare form).
  3. 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.

Build the foundation once. Expand without limits.

BetterSuite is built for teams who see on-demand as a business — not a feature.