Billing with Stripe, Paddle and MetaMask in One Product
BlockNext sells subscriptions (Basic, Pro and Ultra, monthly or yearly) and one-off credit packs. Customers can pay by card through Stripe or Paddle, or with crypto through MetaMask. On paper those are three ways to take money. In practice they're three very different systems: two hosted checkouts with webhooks and automatic renewals, and one wallet transfer that nobody calls you back about.
This post is about keeping that difference out of the rest of the product.
Payments don't know what a subscription is
Billing is split across five modules:
payments sessions, completed payments, provider adapters, webhooks
plans Free / Basic / Pro / Ultra, defined in code
subscriptions the Subscription aggregate and its lifecycle
billing invoices, built from payment events
quota plan limits and an append-only credit ledgerThe most important rule is in the first one. The payments module deliberately doesn't know what a "subscription" or a "plan" is. A payment session is for an opaque item type and item ID, such as subscription and pro:monthly. When a payment completes, payments publishes an event, and the modules that care react to it. Subscriptions creates or extends a subscription, billing writes an invoice, and quota grants credits.
That keeps the provider-specific mess in one module and the business rules in others. Adding a provider doesn't touch subscriptions, and changing how upgrades work doesn't touch Stripe code.
Plans themselves live in code, not in the database. Each price carries the IDs of the matching price objects on Stripe and Paddle, so the same plan maps to the right product on each provider.
A small interface plus optional capabilities
The obvious design is one big interface with every operation: checkout, webhooks, cancel, billing portal, verification. Then the MetaMask adapter has to implement "verify webhook signature" and "open billing portal" as stubs, and every caller has to know which stubs are real.
Instead, every provider implements a small core, and anything optional is a separate interface:
type PaymentProvider interface {
GetProvider() commonDomain.PaymentProvider
GetCategory() commonDomain.PaymentProviderCategory
GetCurrency() string
InitiatePayment(ctx context.Context, options InitiatePaymentOptions) (*PaymentInstructions, error)
SupportsWebhook() bool
SupportsAutoRenew() bool
SupportsBillingPortal() bool
RequiresManualVerification() bool
}
type WebhookCapable interface {
VerifyWebhookSignature(payload []byte, signature string) bool
ParseWebhookPayload(payload []byte) (*WebhookResponse, error)
GetWebhookSignatureHeader() string
}Next to WebhookCapable there are SubscriptionCapable (cancel and undo cancel), BillingPortalCapable, ManualVerificationCapable and CryptoCapable. Callers discover them with a type assertion, guarded by the provider's own declaration:
func AsSubscriptionCapable(p PaymentProvider) (SubscriptionCapable, bool) {
if p == nil || !p.SupportsAutoRenew() {
return nil, false
}
sc, ok := p.(SubscriptionCapable)
return sc, ok
}Stripe and Paddle implement webhooks, subscriptions and the billing portal. MetaMask implements manual verification and crypto. The UI asks the same questions ("can this be cancelled?", "is there a portal?") and gets honest answers, and no caller ever checks a provider's name.
Nine events that mean the same thing everywhere
Stripe and Paddle describe the same business events with different names, different payloads and different edge cases. Each adapter translates its provider's webhooks into one shared set:
checkout.completed checkout.expired
payment.failed payment.refunded payment.disputed
invoice.paid invoice.failed
subscription.updated subscription.deletedThe translation is where the provider knowledge lives. On Stripe, a paid invoice is a renewal unless its billing reason is the first subscription payment. On Paddle, one event type, transaction.completed, means either a first checkout or a renewal, depending on where the transaction came from:
if tx.SubscriptionID != "" && tx.Origin == "subscription_recurring" {
return p.buildRenewalResponse(tx)
}
return p.buildCheckoutResponse(tx)Paddle's "cancel at period end" arrives as a subscription update with a scheduled change. A refund arrives as an adjustment with a refund action. Everything downstream sees only subscription.updated and payment.refunded.
Webhooks: verify, dedupe, and commit together
All providers post to one route, /webhooks/payments/:provider. The processor does the same four steps for each:
- Verify the signature. Both card providers sign with HMAC-SHA256 over a timestamp and the payload, in slightly different header formats. Both checks also reject signatures older than five minutes, so a captured webhook can't be replayed later. Both are implemented with the standard library, with no SDKs.
- Parse into the shared event type.
- Record the event, inside a database transaction. Providers retry webhooks, and sometimes send the same one twice. If the event ID was already seen, the insert does nothing and the handler stops:
- Act on it in that same transaction, and write the resulting domain event to the outbox.
INSERT INTO payments.webhook_events (provider, event_id, ...)
VALUES (...)
ON CONFLICT (provider, event_id) DO NOTHINGBecause the dedup row, the state change and the outbox event commit together, a crash at any point leaves either everything or nothing. The subscribers on the other side use an inbox to handle each event exactly once. That's the same pattern I described in Running a Workflow Platform on 100 MB of RAM Without Redis.
Order matters too. Webhooks can arrive out of order, so a subscription remembers the timestamp of the last provider event it applied and ignores anything older.
MetaMask: no webhook, so you verify yourself
Crypto inverts the flow. Nobody calls you when the payment happens. The user's wallet sends a transaction, and the app has to go and check the chain.
The MetaMask adapter's "checkout" returns payment instructions instead of a URL: the amount, chain ID, receiving wallet and token. The front-end uses ethers to switch the wallet to the right chain, sends the transfer, waits for the receipt, and sends the transaction hash to the backend. The backend then fetches the transaction from a blockchain explorer API and checks it independently of anything the browser claimed:
expectedBaseUnits := paymentsessions.ExpectedTokenBaseUnits(expectedAmountCents)
actualAmount := txDetails.Value
if txDetails.Erc20Amount != nil && txDetails.Erc20Amount.Cmp(big.NewInt(0)) > 0 {
actualAmount = txDetails.Erc20Amount
}
if actualAmount == nil || actualAmount.Cmp(expectedBaseUnits) < 0 {
return ErrAmountMismatch
}A background worker runs the same verification for sessions that are still pending, in case the browser was closed before it reported back. A transaction hash can only ever complete one payment session, which a unique index enforces.
If you build on-chain payments, these are the checks that matter. Each one closes a different way to pay less than the price:
- The transaction succeeded on chain, with enough confirmations for the chain you're on.
- It was sent to your wallet.
- It moved the token you expect, checked against a contract address you fixed on the server, never one the client sent.
- The amount is at least the price, converted with the token's real decimals.
- The same transaction hash can't pay twice.
The other big difference is renewals. A card can be charged every month; a wallet can't. Crypto subscriptions are created without auto-renew, a worker expires them at the end of the period and moves the organization back to the free plan, and renewing means paying again. That's a product decision as much as a technical one, and the UI should make it obvious before people pay.
Upgrades, cancellations and failed payments
- Upgrades are allowed to a plan with a higher score. The old subscription is closed and a new one starts. Downgrades and buying the same plan again are rejected.
- Cancellation is always at the end of the paid period, and can be undone until then. Both card providers support that natively.
- Failed renewals move a subscription to
past_due. Retrying the card and emailing the customer is left to the provider's own dunning. - Credits are an append-only ledger. Monthly plan credits are granted by a worker with an expiry date, and usage is debited up front under a per-organization lock, so two parallel runs can't spend the same credit.
What I'd tell someone adding a second payment provider
- Keep the payment module ignorant of what is being sold. Publish "payment completed for item X" and let the owners of X react.
- Use a small required interface and optional capability interfaces. Stubs that do nothing are bugs waiting for a caller.
- Normalize webhooks into your own small event vocabulary at the edge, and keep provider quirks inside the adapter.
- Dedupe webhooks by event ID in the same transaction as the state change.
- Check plan rules, like "no downgrades", before you take the money, not when the payment event arrives.
- Treat crypto as a different product flow, not just a different button: no callbacks, no automatic renewals, and verification that's entirely your job.