The Stripe client#
go get github.com/AlexGalhardo/vukem/contrib/stripecontrib/stripe is a separate module on the standard library (no stripe-go). Use a restricted key (rk_…).
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 anIdempotency-Key(generated, or yours), so retries on 429, 5xx and network errors are safe;Retry-AfterandStripe-Should-Retryare honoured. - Errors are
*stripe.Error{Status, Type, Code, DeclineCode, Message, Param, RequestID}; match them witherrors.IsagainstErrCardDeclined,ErrInvalid,ErrRateLimited,ErrAuthorErrNotFound(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), andCallfor any other endpoint.stripe.Paramsnests (line_items[0][price]). - Never pass
payment_method_typesto Checkout: dynamic payment methods come from the Dashboard. - The client prints as
rk_…abcd, never the full key.
Webhooks#
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-Signatureis verified (HMAC-SHA256 oft.payload, any of severalv1signatures, ±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 theevents.Busand 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.completedorcheckout.session.async_payment_succeeded, never from the success page. stripe.SignForTestsigns payloads for your tests.
Subscriptions: install:billing#
vukem install:auth # required first: billing bills its users
vukem install:billing
vukem migrateThe scaffold copies user-owned code for the HTMX starter and the Inertia starters (React, Vue, Svelte):
| File | Contents |
|---|---|
config/billing.go | STRIPE_KEY, STRIPE_WEBHOOK_SECRET and one STRIPE_PRICE_<PLAN> per plan (basic, pro) |
database/migrations/<ts>_add_billing_tables.go | users.stripe_customer_id and a subscriptions table |
app/models/billing.go | models.Billable and models.Subscription with Valid() (active or trialing) |
app/billing/billing.go | the Billing service: customer, checkout, portal, webhook sync, middleware |
app/controllers/account/billing*.go, billing page | handlers; resources/views/billing/show.html or resources/js/pages/Billing.* |
routes/billing.go, tests/billing*_test.go | routes, and tests against a fake Stripe API |
Routes#
| Route | Behaviour |
|---|---|
GET /billing | plans 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/portal | redirects to a Billing Portal session |
POST /stripe/webhook | the 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#
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:
stripe listen --forward-to localhost:3000/stripe/webhook # prints the whsec_ secretStripe 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:
// 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"))UpdateAccountV2changes an account (anilmetadata value removes the key).Callsends any other/v2/...endpoint as JSON, with anIdempotency-Keyon 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). CreateLoginLinkopens the seller's Express dashboard;GetTransfer,GetRefundandGetTransferReversalread back what you created. Destination charges are plain params (application_fee_amount,transfer_data[destination]on a PaymentIntent;reverse_transferon a refund).sc.ForAccount("acct_…")or the per-requeststripe.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 withstripe.ErrInvalidRequestOption, so they never run against the wrong account.- Connect events are signed with a different secret than platform events: add it to
WebhookConfig.Secretsand read the seller fromEvent.Account.
The full reference is in the contrib/stripe documentation. The Marketplace showcase app uses Connect in production.