← halfin journalApr 12, 2026 · 10 min read
Engineering

Building a crypto subscription system without a card on file

Invoice-per-cycle, a fresh rate lock every period, entitlement granted off the verified invoice.paid webhook, and dunning that doesn't lock people out on the first miss. The architecture, not the marketing.

RA
R. AdeyemiPayments Engineering
engineering · cover

Subscriptions on cards have a primitive you don't get on-chain: the merchant holds a token and pulls money on a schedule. The customer agrees once, then the network charges them every month until someone cancels.

On-chain there is no pull. Nobody hands you a key to drain their wallet on the first of the month, and you wouldn't want the liability if they did. So the question for a crypto subscription system isn't "how do I store the card." It's "how do I make a push payment feel like a subscription." That reframing decides the entire architecture.

We've built this twice — once badly. This is the version that survived contact with renewals, missed cycles, and a USD-anchored plan whose crypto price drifted between two billing dates.

The core move: one invoice per cycle

There is no auto-charge. Each billing period is a fresh invoice. When a cycle comes due, your billing job creates an invoice for that customer, for that plan, for that period, and sends them a link. They pay it the same way they paid the first one.

This sounds like more work than a card subscription, and it is — for you, the engineer. For the customer it's a link and a wallet confirmation. For the business it removes an entire category of problems: no stored-credential breach surface, no failed re-auth on an expired card, no chargeback reversing a month you already delivered.

The model that works:

  • Plan — the price and cadence. Monthly, $49-equivalent.
  • Subscription — a customer bound to a plan, with a current_period_end and a status.
  • Invoice — one per period, created when the period comes due, tied back to the subscription.

The subscription is the long-lived row. The invoice is disposable: it exists to collect one period's money and then it's history.

Anchor the price in fiat, lock the rate per cycle

If your plan is "$49/month" and you bill in USDT, do not hardcode 49 USDT. Anchor the amount in fiat and let each cycle's invoice carry its own rate lock.

halfin invoices take a fiat-anchored amount: you pass amount_fiat and fiat_currency, the payer picks an asset, and the invoice locks an exchange rate at activation that holds until expiry. Set the amount in USD on every renewal invoice and you bill $49 of value every month, regardless of where BTC or SOL sat that morning. The rate is locked per cycle, at the moment that cycle's invoice activates — not once at signup and frozen forever.

This is the difference between a subscription priced in money and one priced in coins. A coin-priced plan silently re-prices your product every time the market moves; a fiat-anchored one doesn't. The mechanics of that lock are worth understanding in full — we wrote them up in the deferred invoice pattern — but the operating rule is short: anchor in fiat, lock per cycle.

A subtlety teams miss: the customer can pay each cycle in a different asset. They paid in USDC on Base last month and want to pay in USDT on Tron this month. With per-cycle invoices that's free — every invoice is independent, the payer picks the rail each time. A card subscription can't do that at all.

Entitlement comes off the verified webhook, never the redirect

Here is the bug we shipped the first time, so you don't have to.

When a customer pays, the checkout sends them back to your redirect_url. It is extremely tempting to flip their subscription to active right there, on the return. Don't. The redirect means "the customer's browser came back." It does not mean "the money confirmed." A dropped connection, a closed tab, or a customer who pays and never returns will all desync you from reality — in both directions.

Entitlement is granted off the invoice.paid webhook and nothing else. The flow:

  1. Receive the webhook.
  2. Verify the HMAC signature over the raw request bytes before you parse or trust anything. An unverified webhook is an attacker's free POST to your "make this subscription active" endpoint.
  3. Match the event's invoice back to its subscription.
  4. Extend current_period_end by one cycle and set the subscription active.

The webhook is the truth; the redirect is a UX nicety. We argued this case at length in the redirect URL and why the webhook is truth, and it applies verbatim to subscriptions. The only thing subscriptions add is that the consequence of getting it wrong recurs every month.

A few real events to handle, because the lifecycle isn't just paid:

  • invoice.confirming — deposit seen, not yet final. Good for a "payment received, confirming" UI state; not good enough to grant access.
  • invoice.paid — confirmed. This is your entitlement trigger.
  • invoice.underpaid — they sent less than the locked amount. Don't extend the period; surface a top-up.
  • invoice.expired — the cycle's invoice lapsed unpaid. This is your dunning trigger, below.

Process by the event's id and make the handler idempotent. Webhooks retry; receiving invoice.paid twice must extend the period once. If you've ever built a payout runner you already know this discipline — same rule, designing an idempotent payout runner covers the dedup pattern we reuse here.

Dunning: a missed cycle is not a cancellation

Card subscriptions get an involuntary-churn grace period — the network retries the card for days before anyone gives up. You need the same forgiveness, built by hand, because the failure mode here is different. A card "fails." A crypto invoice just... doesn't get paid yet. The customer is on a plane, the gas spiked, they meant to do it Tuesday.

So missing a cycle should not instantly revoke access. It should start a clock.

The dunning ladder we run:

  1. Cycle comes due — create the renewal invoice, email the link. Subscription stays active; current_period_end is in the near future.
  2. Period end passes, invoice still unpaid — enter a past_due grace window. Access continues. Send reminder one.
  3. invoice.expired fires — the specific invoice lapsed. Issue a fresh one (the rate has moved, so it relocks at the current rate) and send reminder two with the new link.
  4. Grace window exhaustednow suspend. Access off, subscription suspended, not deleted.
  5. They pay the outstanding invoice during or after graceinvoice.paid arrives, you reactivate and extend. No re-signup, no lost history.

Two engineering notes that save you grief. First, dun against the invoice, not a guessed timestamp: invoice.expired is an event the system hands you, so let it drive the ladder instead of a cron that infers "probably unpaid by now." Second, suspend, don't delete — a suspended subscription that pays late is a recovered customer; a deleted one is a re-acquisition cost.

What you do not get, and shouldn't fake

Be honest with yourself about the trade. There is no silent renewal. Every cycle, the customer takes an action. For some products that's a feature — explicit, consent-driven, no surprise charges, the thing privacy-minded buyers actually prefer. For impulse-priced consumer products billed at $4/month, the per-cycle friction is real and you should know it going in.

What you must not do is fake the pull by holding funds or pre-charging a balance the customer didn't authorize for that period. The honest model is push-per-cycle with great reminders. We made the business case for it — when it fits and when it doesn't — in recurring crypto billing without a saved card.

Putting it together

A workable build, end to end:

  • A plans table (fiat price + cadence) and a subscriptions table (status, current_period_end, plan_id, customer_id).
  • A daily job that finds subscriptions whose current_period_end is within your renewal lead time and creates a fiat-anchored invoice per cycle, tied back to the subscription id.
  • One webhook endpoint: verify signature on raw bytes, then invoice.paid extends the period, invoice.underpaid prompts a top-up, invoice.expired advances the dunning ladder. Idempotent on event id.
  • A state machine on the subscription: active → past_due → suspended → active, with the webhook events as the only transitions that grant access.

The runnable, code-level version of this — the billing job, the signature verification, the handler switch — lives in our guide on how to implement recurring crypto payments, and the product framing for the SaaS case sits at recurring crypto billing for SaaS.

The whole system rests on one inversion: you don't pull money on a schedule, you ask for it on a schedule, and you treat the confirmed webhook — not the customer's browser, not a cron's guess — as the only thing that says they paid. Get that right and the rest is a state machine and some well-timed emails.

R. Adeyemi, halfin payments engineering

↳ end of articlehalfin journal · Apr 12, 2026