← halfin journalJun 03, 2026 · 8 min read
Product

Recurring crypto billing without a saved card

There is no card to vault and no instrument to auto-charge, so stop pretending. The honest pattern is an invoice per cycle with a fresh rate lock, and entitlements driven off the verified webhook.

LT
L. TanakaProduct
product · cover

You cannot store a crypto "card on file." There is no card. Build the renewal you can actually deliver, not the one your billing vendor's UI implies.

Every team that ships a subscription product eventually asks us the same thing: "Can we auto-charge a customer's wallet every month?" The honest answer is no, and the teams that accept that early ship something solid. The teams that fight it spend a quarter building a leaky imitation of card billing and then rebuild it anyway.

A card stores a token you can debit later, with the customer's standing authorization. A self-custody wallet stores nothing on your side. There is no mandate, no token, no pull. The customer initiates every payment, every time. That is not a limitation to engineer around. It is the security model, and it is the one your crypto-native customers actually want.

So the pattern is not "charge the saved instrument." It is invoice per cycle: a fresh, fiat-anchored invoice at the start of each billing period, with its own rate lock, that the customer pays from whatever wallet they like.

What "recurring" really means here

Strip the word "subscription" of its card-billing baggage and you are left with two jobs:

  1. Produce an invoice on a schedule. At each renewal boundary, your system creates a new invoice for the plan amount.
  2. Grant or extend access when that invoice is paid. Not when it's created. When it's paid, confirmed on-chain, and the webhook says so.

That is the whole machine. Everything else — dunning, grace periods, plan changes — is logic you layer on top of those two events. We walk through the mechanics on the SaaS use-case page, but the core loop is small enough to hold in your head.

Anchor the price in fiat, lock the rate per cycle

Price your plan in fiat. A $49/month plan is a $49/month plan. Charging "0.0007 BTC" is a trap: the dollar value of that drifts every block, and your customer's bank statement, your revenue recognition, and your support team all live in fiat.

So each cycle, you create a fiat-anchored invoice:

{
  "amount_fiat": "49.00",
  "fiat_currency": "USD",
  "deferred": true
}

deferred: true matters for renewals. It means the invoice is created without committing to an exchange rate yet — the rate locks when the customer activates and picks a settlement asset, not at the instant your cron job fired. The customer pays exactly the locked crypto amount for the locked window. If they pay late or not at all, no stale rate is hanging over the account. How invoicing handles the fiat anchor and rate lock goes deeper, but the renewal-specific takeaway is this: the rate belongs to the cycle, not to the subscription. Each period gets its own lock, its own expiry, its own clean accounting.

This is also why you don't need to know in advance whether this month's customer pays in USDT on Tron, USDC on Base, or BTC. The invoice is denominated in dollars; the asset is the customer's choice at pay time.

Drive entitlements off invoice.paid, and verify it

Here is the part teams get wrong: they extend access the moment they create the renewal invoice, because that's when their scheduler runs and it's convenient. Don't. An unpaid invoice is a bill, not a payment. Grant nothing until the money is real.

The signal that the money is real is the invoice.paid webhook. It fires after the deposit is seen and confirmed to your configured threshold on the relevant chain — these gates are reorg-aware, so "confirmed" means confirmed, not "saw it in the mempool." When you receive it:

  1. Verify the HMAC signature on the raw body before you trust a single field. An unverified webhook is just a POST from the internet, and "extend any account named in this payload" is a generous thing to expose. Verify first, parse second.
  2. Match the event's invoice to the subscription it renews. Carry your own subscription id through invoice metadata so the join is a lookup, not a guess.
  3. Extend the entitlement: push the access window out by one billing period.
  4. No-op if you've already processed this event id. The same invoice.paid can arrive twice — a retry, a replay — and processing it twice must not gift a free month.

We wrote a whole separate piece on making that delivery survivable — designing webhooks that survive everything — and the headline applies here exactly: idempotency and signature verification are not optional polish. They are the contract.

The states a real subscription actually has

Card billing hides its failure modes behind "retry the charge." You don't have a charge to retry, so you have to model the states honestly. They are not complicated:

  • Active. A paid invoice covers the current window. Access is on.
  • Awaiting renewal. A new-cycle invoice exists and is unpaid. Access is still on, because the previous window hasn't closed yet. This is your renewal window — send the invoice link early.
  • Underpaid / overpaid. The customer sent less or more than the locked amount. You'll get invoice.underpaid or invoice.overpaid instead of invoice.paid. Underpaid means access does not extend yet — surface a top-up. Overpaid means you owe a decision: credit the next cycle or refund the difference. Don't silently keep it.
  • Lapsed. The renewal window closed with no qualifying payment. The invoice hit invoice.expired; access ends. The next renewal is a fresh invoice, not a "past due" balance you try to collect against a wallet you can't touch.

Notice what's absent: there is no "card declined," no "retry in 3 days against the same instrument," no failed-payment dunning loop trying to debit something. Your dunning is just reminding a human to pay an invoice you already created. That's an email with a link, not a payments-engine feature.

A renewal loop that actually works

Put together, the loop your scheduler runs is short:

  1. A few days before the period ends, create the next fiat-anchored, deferred invoice for the plan amount. Stamp your subscription id into its metadata.
  2. Send the customer the hosted invoice link. They pay from any supported wallet, any supported asset — BTC, ETH and ERC-20s, the Base / Arbitrum / Polygon L2s, BSC, Tron, XRP, Solana — whatever they hold.
  3. Wait for invoice.paid. Verify the signature, dedupe on event id, extend the window.
  4. If the window closes first, the invoice expires and the subscription lapses. Re-engage with a fresh invoice, not a retry.

That's it. No vaulted instrument, no off-session mandate, no pretending you can pull funds you were never given the keys to pull.

Why this is better, not just necessary

It's tempting to read all of this as "the constraint we tolerate because crypto can't do card-on-file." It reads better the other way around. The customer keeps custody and authorizes every payment explicitly — there's no standing permission to leak or abuse. Each cycle is independently priced and independently accounted, which is a gift to your finance team, not a burden. And a lapsed subscription leaves no dangling authorization and no awkward "we tried to charge your card" email — it just stops, cleanly, until the next invoice is paid.

Build the renewal you can deliver. It's smaller than the one you were imagining, and it's the honest one.

L. Tanaka, halfin product

↳ end of articlehalfin journal · Jun 03, 2026