DEV Community

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)
  • Client365 is built fresh, credentials picked based on IsDefault
  • 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.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)
}

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

Comments

No comments yet. Start the discussion.