The problem
We build Verify365, a white-label SaaS platform used by several law firms, each under its own brand. Every partner firm needs payments to land directly in their own Stripe account — not a shared platform account — while the platform still needs to skim a processing fee before the rest of the money moves on.
Stripe Connect solves the "money to the right account" part. The interesting engineering problem was building a clean, per-partner client pattern around it in Go.
The design
We wrote a small stripe365 package that wraps Stripe's Go SDK, with one core rule:
One
Client365instance per request — never a shared, app-wide client.
Each instance is initialised with either the partner's own Stripe credentials, or the platform's default credentials as a fallback. Everything downstream (fee math, transfer destination, webhook secret) branches off an IsDefault flag set at construction time.
Here's the flow, end to end:
- Request comes in for a partner (or the platform default)
-
Client365is built fresh, credentials picked based onIsDefault - Checkout session created, with fee math applied only for partner accounts
- Stripe routes the net amount to the partner's connected account via
TransferData - Webhook confirms the payment on the correct endpoint (platform vs. connect)
Per-request client initialisation
Instead of building one global Stripe client at app startup, we build a fresh one on every call, picking credentials based on whether the partner has their own Stripe account configured:
// thirdparty/stripe365/Stripe365.go
func NewStripeClient(logger *zerolog.Logger, partner *domain.Partner) Client365 {
stripeKey := ""
stripeSecret := ""
stripeWebhook := ""
stripeWebhookConnect := ""
isDefault := false
if partner == nil || partner.StripeSecret == "" {
// Platform default — env vars
stripeKey = os.Getenv("STRIPE_KEY")
stripeSecret = os.Getenv("STRIPE_SECRET")
stripeWebhook = os.Getenv("STRIPE_WEBHOOK")
stripeWebhookConnect = os.Getenv("STRIPE_WEBHOOK_CONNECT")
isDefault = true
} else {
// Partner-specific Stripe account
stripeKey = partner.StripeKey
stripeSecret = partner.StripeSecret
stripeWebhook = partner.StripeWebhook
stripeWebhookConnect = partner.StripeWebhookConnect
}
stripeClient := &client.API{}
stripeClient.Init(stripeSecret, nil)
return &client365{
StripeClient: stripeClient,
IsDefault: isDefault,
StripeWebhook: stripeWebhook,
StripeWebhookConnect: stripeWebhookConnect,
// ...
}
}
Building the client per-request keeps things stateless and rules out one partner's credentials accidentally leaking into another partner's request — a real risk if you cache a Stripe client keyed by tenant in a long-lived map.
Fee math and transfer routing
Partner accounts get a platform fee deducted before the rest is routed to their connected account. The platform's own default account skips that — the platform just eats Stripe's processing cost instead:
const (
StripeFeePercentage = 0.0185 // 1.85% — to be confirmed with client
StripeFeeFixed = 0.20 // £0.20 fixed per transaction
)
func (c *client365) CreateCheckoutSession(...) (*stripe.CheckoutSession, error) {
amount := payment.Amount * 100 // pounds → pence
roundedResult := math.Round(amount)
netAmount := roundedResult
platformFee := 0.0
if !c.IsDefault {
// Partner account: deduct platform fee before routing
platformFee = (payment.Amount * StripeFeePercentage) + StripeFeeFixed
netAmount = math.Round((payment.Amount - platformFee) * 100)
}
// IsDefault: netAmount = full amount (platform covers Stripe fees)
params := &stripe.CheckoutSessionParams{
// ...line items, mode, URLs...
PaymentIntentData: &stripe.CheckoutSessionPaymentIntentDataParams{
StatementDescriptor: stripe.String(fmt.Sprintf("V365 %s", payment.DisplayId)),
Description: stripe.String(description),
TransferData: &stripe.CheckoutSessionPaymentIntentDataTransferDataParams{
Amount: stripe.Int64(int64(netAmount)),
Destination: stripe.String(stripeAccountId), // the connected account ID
},
},
}
return c.StripeClient.CheckoutSessions.New(params)
}
Two things worth calling out:
- Everything gets converted to integer pence and passed through
math.Round()before touching Stripe — floating-point drift is a classic way to end up with amounts like£10.000000001. -
TransferData.Destinationis what actually routes the net amount to the partner's connected account at checkout time, rather than requiring a separate manual transfer afterwards.
Onboarding partners with Stripe Express
New partner firms get a Stripe Express account rather than a full custom onboarding flow — Stripe handles the KYC UI, and we just create the account and hand back an onboarding link:
func (c *client365) CreateAccount(request domain.StripeAccountInfo, user domain.User) (*stripe.Account, error) {
params := &stripe.AccountParams{
Capabilities: &stripe.AccountCapabilitiesParams{
CardPayments: &stripe.AccountCapabilitiesCardPaymentsParams{Requested: stripe.Bool(true)},
Transfers: &stripe.AccountCapabilitiesTransfersParams{Requested: stripe.Bool(true)},
},
Country: stripe.String(request.Country),
Email: stripe.String(user.Email),
Type: stripe.String(string(stripe.AccountTypeExpress)),
Settings: &stripe.AccountSettingsParams{
Payouts: &stripe.AccountSettingsPayoutsParams{
Schedule: &stripe.PayoutScheduleParams{
Interval: stripe.String(string(stripe.PayoutIntervalDaily)),
},
},
},
}
return c.StripeClient.Account.New(params)
}
func (c *client365) CreateAccountLink(id string, request domain.StripeAccountInfo) (*stripe.AccountLink, error) {
params := &stripe.AccountLinkParams{
Account: stripe.String(id),
RefreshURL: stripe.String(request.RefreshURL),
ReturnURL: stripe.String(request.ReturnURL),
Type: stripe.String("account_onboarding"),
}
return c.StripeClient.AccountLinks.New(params)
}
Daily payout schedules keep cash flow predictable for the law firms on the other end.
Two webhook endpoints, two secrets
Platform-level events (a checkout completing on the platform's own account) and connected-account events (transfers, connected-account payouts) come through separate webhook endpoints, each verified with its own secret:
// Both registered in the Gin router
router.POST("/webhook/stripe", paymentController.HandleStripeWebhook)
router.POST("/webhook/stripe/connect", paymentController.HandleStripeConnectWebhook)
// In PaymentService:
func (s *PaymentService) HandleWebhook(payload []byte, signature string, isConnect bool) error {
webhookSecret := s.stripeClient.GetStripeWebhook()
if isConnect {
webhookSecret = s.stripeClient.GetStripeWebhookConnect()
}
event, err := webhook.ConstructEvent(payload, signature, webhookSecret)
if err != nil {
return fmt.Errorf("stripe webhook signature verification failed: %w", err)
}
switch event.Type {
case "checkout.session.completed":
// Update Payment.status → "paid"
case "payment_intent.payment_failed":
// Update Payment.status → "failed"
case "transfer.created":
// Reconcile connected account transfer
}
return nil
}
Mixing up STRIPE_WEBHOOK and STRIPE_WEBHOOK_CONNECT is an easy mistake, and it fails quietly with a signature-verification error rather than an obvious "wrong secret" message — worth a comment or two in the code so future-you doesn't lose an afternoon to it.
Takeaways
- One client per request, not one client per app. Building the Stripe client inside the request path (rather than once at startup) keeps the logic stateless and avoids cross-tenant credential leakage.
-
Fee logic only applies to partner accounts. The
IsDefaultflag is the single switch that decides whether the fee formula runs at all. - Stripe Express removes a whole KYC flow from your scope. Let Stripe own onboarding UI and compliance; you just create the account and generate the link.
- Two webhook secrets, two endpoints. Don't try to multiplex platform and connected-account events through one handler with one secret.
- Round to integer pence before calling Stripe. Floating-point math and money don't mix.
Stack: Go · stripe-go/v72 · Stripe Connect · Stripe Express Accounts · PostgreSQL · Gin
Building multi-tenant payments in a Go SaaS? Happy to compare notes in the comments.
Top comments (3)
solid writeup. one thing worth making explicit for anyone copying it: TransferData.Destination without on_behalf_of means the PaymentIntent settles on your platform account, so by default you (not the partner firm) own the chargeback liability, and the statement descriptor is yours too.
if you want each firm to actually be the merchant of record, own their disputes, and show up on their own statement, add on_behalf_of pointing at the connected account. it stays a destination charge, just one extra field. full direct charges via the Stripe-Account header get you there as well but move where the webhooks and the fee live, so on_behalf_of is usually the smaller step.
(i work on affiliate/commission tracking on Stripe, so i end up living in connected-account routing more than i'd like.)
Really appreciate this — and you're right, that's a real gap in the writeup, not just a nitpick. As written,
TransferData.Destinationalone does make the platform the merchant of record: platform eats the chargeback risk and the statement descriptor staysV365 ...regardless of which firm's card got charged. That's... not what we actually want long-term, so thanks for putting a name on it.Going to test adding
OnBehalfOfpointing at the connected account id alongside the existingTransferDatathis week. One thing I'm not 100% clear on: does settingon_behalf_ofchange anything about which account's payout schedule or risk/KYC requirements apply once disputes start flowing to the connected account, or is that orthogonal to the daily payout setup we already have on the Express accounts? Curious if you've hit edge cases there in the affiliate/commission work.Appreciate you taking the time to write this out.
Sixteen days late, sorry. Your question first, then a correction to what I told you, because I got one part wrong and you said you were shipping it.
Payout schedule: orthogonal, you are fine. With on_behalf_of, Stripe pays out the connected account according to that account's own delay_days, so the daily schedule you already have on the Express accounts carries on unchanged.
KYC and capabilities: not orthogonal, and this is the real cost of the change. To accept a payment method on a charge with on_behalf_of you have to request that payment method's capability on the connected account, so card_payments rather than transfers alone, and card_payments has transfers as a prerequisite. With transfers only, your platform processes the charge. So each firm takes on a heavier verification ask than what they clear today.
Settlement moves as well: the charge settles in the connected account's country, uses that country's fee structure, and shows their descriptor and address on the customer's statement. For any firm outside your country that changes both settlement currency and pricing.
Now the correction. I said on_behalf_of would let each firm own their disputes. That is wrong for destination charges. Stripe debits dispute amounts and fees from the platform account with or without on_behalf_of, and you recover by reversing the transfer. Only full direct charges move dispute liability onto the connected account. The merchant-of-record presentation moves, the chargeback money does not, and I ran the two together.
One gotcha worth having before you ship: on cross-border destination charges with on_behalf_of, wait until a dispute is actually lost before reversing the transfer to recover it. Retransferring a reversal runs into cross-border restrictions, so clawing back early and then winning can leave you with no way to pay the firm back.