Skip to content

Digging deeper

Billing with Stripe

A stdlib Stripe client with retries and idempotency keys, verified webhooks, and the install:billing scaffold for subscriptions with Checkout and the Billing Portal.

Last updated:

The Stripe client#

Terminal
go get github.com/AlexGalhardo/vukem/contrib/stripe

contrib/stripe is a separate module on the standard library (no stripe-go). Use a restricted key (rk_…).

Go
sc, err := stripe.New(cfg.Key.Reveal(), stripe.WithRetries(2)) // cfg.Key is a config.Secret

session, err := sc.CreateCheckoutSession(ctx, stripe.Params{
	"mode":                    "subscription",
	"customer":                customerID,
	"line_items[0][price]":    priceID,
	"line_items[0][quantity]": "1",
	"success_url":             "https://app.example.com/billing?checkout=success",
})

pi, err := sc.CreatePaymentIntent(ctx, params, "order-42") // your own idempotency key
if errors.Is(err, stripe.ErrCardDeclined) { /* ... */ }
  • Every request pins Stripe-Version (stripe.APIVersion). POSTs carry an Idempotency-Key (generated, or yours), so retries on 429, 5xx and network errors are safe; Retry-After and Stripe-Should-Retry are honoured.
  • Errors are *stripe.Error{Status, Type, Code, DeclineCode, Message, Param, RequestID}; match them with errors.Is against ErrCardDeclined, ErrInvalid, ErrRateLimited, ErrAuth or ErrNotFound (the same sentinels match API v1 and v2 errors).
  • Resources: customers (Create/Get/UpdateCustomer), Checkout Sessions, subscriptions (Get/Update/CancelSubscription), CreatePortalSession, payment intents, refunds (CreateRefund/GetRefund), Connect (below), and Call for any other endpoint. stripe.Params nests (line_items[0][price]).
  • Never pass payment_method_types to Checkout: dynamic payment methods come from the Dashboard.
  • The client prints as rk_…abcd, never the full key.

Webhooks#

Go
c, err := app.Cache()
if err != nil {
	return err
}
webhook, err := stripe.WebhookHandler(stripe.WebhookConfig{
	Secret: cfg.WebhookSecret.Reveal(), Store: c.Store(), Bus: app.Events, Log: app.Log,
})

events.Listen(app.Events, func(ctx context.Context, r stripe.Received) error {
	switch r.Event.Type {
	case "checkout.session.completed", "checkout.session.async_payment_succeeded":
		cs, err := stripe.Decode[stripe.CheckoutSession](r.Event)
		if err != nil {
			return err
		}
		return fulfil(ctx, cs)
	}
	return nil
})

Stripe is a server, not a browser: mount the handler ahead of the session and CSRF middleware (the billing scaffold wraps it in a server middleware registered with r.UseServer). A valid signature is its authentication.

  • Stripe-Signature is verified (HMAC-SHA256 of t.payload, any of several v1 signatures, ±5 minutes tolerance).
  • The event id is claimed in a cache.Store (memory, database or Redis), so a duplicate delivery is acknowledged without processing. stripe.Received{Event} goes to the events.Bus and the event is marked done only after that; a failing listener releases the claim and answers 500 so Stripe redelivers.
  • Fulfil orders from checkout.session.completed or checkout.session.async_payment_succeeded, never from the success page.
  • stripe.SignForTest signs payloads for your tests.

Subscriptions: install:billing#

Terminal
vukem install:auth       # required first: billing bills its users
vukem install:billing
vukem migrate

The scaffold copies user-owned code for the HTMX starter and the Inertia starters (React, Vue, Svelte):

FileContents
config/billing.goSTRIPE_KEY, STRIPE_WEBHOOK_SECRET and one STRIPE_PRICE_<PLAN> per plan (basic, pro)
database/migrations/<ts>_add_billing_tables.gousers.stripe_customer_id and a subscriptions table
app/models/billing.gomodels.Billable and models.Subscription with Valid() (active or trialing)
app/billing/billing.gothe Billing service: customer, checkout, portal, webhook sync, middleware
app/controllers/account/billing*.go, billing pagehandlers; resources/views/billing/show.html or resources/js/pages/Billing.*
routes/billing.go, tests/billing*_test.goroutes, and tests against a fake Stripe API

Routes#

RouteBehaviour
GET /billingplans and the current subscription; ?checkout=success only shows a note and grants nothing
POST /billing/checkout/{plan}creates the Stripe customer on first use, then a Checkout Session in subscription mode, and redirects (303)
POST /billing/portalredirects to a Billing Portal session
POST /stripe/webhookthe verified webhook, mounted with r.UseServer ahead of sessions and CSRF

Redirects leave the app as a 303 for forms, inertia.Location for Inertia visits, HX-Redirect for htmx and {"url": …} for JSON clients.

Webhooks keep subscriptions in sync#

Access comes from webhooks only. The scaffold handles checkout.session.completed, checkout.session.async_payment_succeeded, customer.subscription.* (created, updated, deleted, paused, resumed), invoice.paid and invoice.payment_failed. Each sync fetches the subscription from Stripe (events can arrive out of order) and upserts it. Processed event ids live in the app cache, so use a shared store (CACHE_STORE=database or Redis) with several instances.

Gating routes#

Go
r.Get("/reports", reports).Use(auth.Required(acc.Web), b.Subscribed())
r.Get("/exports", exports).Use(auth.Required(acc.Web), b.OnPlan("pro"))

Users without a valid subscription (or on the wrong plan) are redirected to /billing with a flash message, or get 402 problem+json. Missing users, unknown plans and database errors deny too.

Configuration#

In production the app refuses to boot while STRIPE_KEY, STRIPE_WEBHOOK_SECRET or a plan price is missing; elsewhere billing answers 503 until they are set. Develop against a Stripe sandbox:

Terminal
stripe listen --forward-to localhost:3000/stripe/webhook   # prints the whsec_ secret

Stripe Connect (marketplaces)#

A marketplace takes the payment and pays part of it out to a seller. The client covers Stripe Connect with Accounts v2, Stripe's current API for connected accounts (the Marketplace showcase saw a new platform's v1 account creation refused), plus the v1 transfers, refunds and reversals that pay and claw back sellers:

Go
// 1. Create the seller's connected account (Express dashboard, platform owns fees and losses)
//    and send them to Stripe's onboarding. v2 bodies are JSON; Params nest.
acct, err := sc.CreateAccountV2(ctx, stripe.Params{
	"contact_email": seller.Email,
	"dashboard":     "express",
	"identity":      stripe.Params{"country": "br"},
	"defaults": stripe.Params{"responsibilities": stripe.Params{
		"fees_collector": "application", "losses_collector": "application",
	}},
	"configuration": stripe.Params{
		"merchant":  stripe.Params{"capabilities": stripe.Params{"card_payments": stripe.Params{"requested": true}}},
		"recipient": stripe.Params{"capabilities": stripe.Params{
			"stripe_balance": stripe.Params{"stripe_transfers": stripe.Params{"requested": true}},
		}},
	},
	"include": []string{"configuration.recipient", "requirements"},
}, stripe.IdempotencyKey("shop-7-account"))
link, err := sc.CreateAccountLinkV2(ctx, stripe.Params{
	"account": acct.ID,
	"use_case": stripe.Params{
		"type": "account_onboarding",
		"account_onboarding": stripe.Params{
			"configurations": []string{"merchant", "recipient"}, // required on the pinned API version
			"refresh_url":    appURL + "/seller/onboarding",
			"return_url":     appURL + "/seller",
		},
	},
}) // single use: redirect only the signed-in account holder to link.URL

// 2. After onboarding: the seller can be paid once stripe_transfers is active.
acct, err = sc.GetAccountV2(ctx, acct.ID, []string{"configuration.recipient"})
ready := acct.Configuration.Recipient != nil &&
	acct.Configuration.Recipient.Capabilities.StripeBalance.StripeTransfers.Status == "active"

// 3. Separate charges and transfers: pay the seller's share, and on a refund
//    reverse it first, then refund the buyer. Keys tie retries to the order.
tr, err := sc.CreateTransfer(ctx, stripe.Params{
	"amount": 9000, "currency": "brl", "destination": acct.ID, "transfer_group": "order-42",
}, stripe.IdempotencyKey("order-42-transfer"))
_, err = sc.CreateTransferReversal(ctx, tr.ID, stripe.Params{"amount": 9000},
	stripe.IdempotencyKey("order-42-reversal"))
_, err = sc.CreateRefund(ctx, stripe.Params{"payment_intent": piID, "amount": 10000},
	stripe.IdempotencyKey("order-42-refund"))
  • UpdateAccountV2 changes an account (a nil metadata value removes the key). Call sends any other /v2/... endpoint as JSON, with an Idempotency-Key on POST and DELETE.
  • The v1 methods (CreateAccount, GetAccount, CreateAccountLink) still work for platforms that already use them, and Stripe still requires v1 for OAuth, Treasury and card issuing; Stripe discourages keeping both versions indefinitely. A v2 account id works with every v1 endpoint (transfers, Stripe-Account).
  • CreateLoginLink opens the seller's Express dashboard; GetTransfer, GetRefund and GetTransferReversal read back what you created. Destination charges are plain params (application_fee_amount, transfer_data[destination] on a PaymentIntent; reverse_transfer on a refund).
  • sc.ForAccount("acct_…") or the per-request stripe.OnAccount("acct_…") act as a connected account (direct charges, and refunds of them). The platform-only methods (accounts v1 and v2, account and login links, transfers and reversals) refuse that scope with stripe.ErrInvalidRequestOption, so they never run against the wrong account.
  • Connect events are signed with a different secret than platform events: add it to WebhookConfig.Secrets and read the seller from Event.Account.

The full reference is in the contrib/stripe documentation. The Marketplace showcase app uses Connect in production.