Multi-Tenant Stripe Connect Payments in a Go SaaS Platform
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 Client365 instance 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.
Comments
No comments yet. Start the discussion.