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 ledger

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

The 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:

  1. 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.
  2. Parse into the shared event type.
  3. 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:
  4. 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 NOTHING

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

What I'd tell someone adding a second payment provider

← All posts