DEV Community

Northbeam Technologies
Northbeam Technologies

Posted on

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:

  1. Request comes in for a partner (or the platform default)
  2. Client365 is built fresh, credentials picked based on IsDefault
  3. Checkout session created, with fee math applied only for partner accounts
  4. Stripe routes the net amount to the partner's connected account via TransferData
  5. 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,
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.Destination is 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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 IsDefault flag 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 (1)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

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.)