Payment Webhooks

What BetterSuite receives from your payment service provider, how we route it, and what — honestly — is and isn't exposed to your code today.

Last updated May 19, 2026

This page documents the inbound path: your PSP (Stripe today) calls a BetterSuite endpoint and we update payment state on your behalf. It does not describe an outbound webhook surface where BetterSuite calls back to your code — that does not exist as a tenant-facing feature right now. If you're trying to react to payment events from your own code, see Subscribing to events below for what's actually wired.

The endpoint

The payment service registers a single route per PSP account:

POST /webhooks/{tenant_id}/{psp_account_id}

In production that resolves to https://api.bettersuite.io/webhooks/{tenant_id}/{psp_account_id}. The two path segments are the load-bearing routing key — there is no separate {provider} segment, because the PSP account ID already identifies the provider and credentials.

The route is defined in backend/crates/monolith/src/unified_schema/router.rs:163-167 (payment_psp_webhook_handler) and again in the standalone payment service at crates/payment/application/src/router.rs:22-25. It bypasses authentication — the request is trusted only after we re-verify the PSP signature inside ProcessWebhookUseCase (see Signature Verification).

Which provider is actually wired

Only Stripe is wired end-to-end today. The other PSP plugins in backend/crates/psp-plugins/ are present but unfinished:

ProviderWebhook receiveSignature verifyWebhook auto-provision
StripeYesYes (Stripe-Signature header, Stripe SDK Webhook::construct_event)Yes
MercadoPagoStub — returns PspError::UnsupportedStubNot implemented

So if you're on Stripe, the rest of this page applies. If you're waiting on another provider, this page will need updates when that integration lands.

Events Stripe sends us

When BetterSuite auto-provisions the Stripe endpoint, it asks Stripe to fire exactly the events in BETTERSUITE_STRIPE_EVENTS (defined in backend/crates/payment/application/src/psp_accounts/provision_psp_webhook_usecase.rs:16-30):

Stripe eventWhat BetterSuite does on receipt
payment_intent.succeededMoves the matching PaymentAttempt to Captured. For wallet top-ups, credits the wallet idempotently.
payment_intent.payment_failedMoves the attempt to Failed.
payment_intent.canceledMoves the attempt to Canceled.
payment_intent.processingMoves the attempt to Processing.
payment_intent.requires_actionMoves the attempt to RequiresAction (e.g. 3DS challenge).
payment_intent.amount_capturable_updatedMoves the attempt to RequiresCapture for delayed-capture flows.
charge.refunded / charge.refund.updatedUpdates refunded_minor on the attempt to the running total.
payment_method.attachedMirrors the new card into the local payment_methods table; sets it as default if the account has no other default.
payment_method.detachedRemoves the local mirror.
charge.dispute.created / .updated / .closedLogged, but full dispute handling is not implemented yet.

The dispatch lives in ProcessWebhookUseCase::handle_event (backend/crates/payment/application/src/webhooks/process_webhook_usecase.rs:137-189). Anything else Stripe sends is logged at debug and ignored.

There is no normalized event taxonomy on top of these. We don't translate Stripe's payment_intent.succeeded into something like payment.intent.confirmed — those names existed in earlier drafts of this page but never in the code. When you read backend logs or build against payment data, the Stripe event type is what you'll see.

Subscribing to events

Since outbound webhooks aren't a thing yet, here's what you can actually do.

Query the result via GraphQL

After a webhook arrives and the use case settles, the change is durable in Postgres. Polling or querying the relevant entity is the supported pattern:

  • myPaymentMethods and paymentMethod(id) — reflect payment_method.attached / detached.
  • The order or wallet top-up entity that owns the PaymentAttempt — reflects the captured / failed / refunded transitions.
  • Wallet accountTransactions — reflects the idempotent wallet credit when a wallet_topup PaymentIntent captures.

There is no paymentEvents GraphQL subscription. The type Subscription block in the federated schema (see backend/schemas/schema.graphql:34722-34801) currently exposes driverEvents, passengerEvents, shopCustomerEvents, vendorEvents, parkingUserEvents, and adminEvents — none of those carry generic payment lifecycle events.

Listen at the PSP directly (advanced)

If you need raw-event fidelity and own the integration, you can register a second Stripe webhook endpoint on the same account pointing at your own service. Stripe is happy to deliver to multiple endpoints, and BetterSuite won't notice or be affected. You verify the signature with your own whsec_… and react however you like. Note that BetterSuite still owns the canonical state — anything you do on top is best treated as a side-channel.

Idempotency and retries

Stripe handles retries at its end. We don't run an internal retry queue for webhooks. If a delivery fails (we return non-2xx), Stripe will retry per its own schedule — see Stripe's webhook docs. The handlers are written to be idempotent: re-applying payment_intent.succeeded to an already-captured attempt is a no-op (process_webhook_usecase.rs:388-400), wallet credits use a deterministic idempotency key (topup:{attempt_id}), and payment_method.attached checks for an existing local row before inserting.

What's next

Build the foundation once. Expand without limits.

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