2026-09-19

Designing a USDC Recurring Billing API

A USDC recurring billing system is not a crypto version of Stripe Billing. The difference isn't cosmetic. Card networks let a merchant initiate a charge after collecting a mandate from a bank. A self-custodial wallet can't safely become a standing debit order just because a customer connected it once — there's no bank in the middle agreeing to honor that mandate on your behalf.

That constraint is the point, not an obstacle to design around. If your subscription system can move customer funds indefinitely with no fresh, inspectable authorization, you've recreated the worst part of traditional payments — opaque pull authority — with more complicated failure modes and none of the regulatory backstops.

For SaaS, marketplaces, and usage-based products, the right design starts with a hard question: what exactly has the customer authorized, on which network, from which wallet, for how much, and until when? Klappay doesn't have a subscriptions product — what it has is charges, deterministic addresses, splits, and events, and this post is about building recurring billing correctly on top of those primitives.


A recurring billing system needs an authorization model

"Recurring" can describe several very different payment mechanics, and treating them as interchangeable is what causes billing disputes and unsafe wallet permissions down the line.

The simplest model is a scheduled payment request. At renewal, your backend creates a new USDC charge for the invoice amount, and the customer signs or sends the transaction from their wallet. This preserves clear consent and works with any ordinary wallet. The trade-off is obvious: the customer has to come back and approve every single invoice.

A second model uses pre-authorized token allowances. The customer approves a contract or spender to move up to a defined amount of USDC, and your billing service submits periodic transfers within that limit. This cuts renewal friction, but it changes the security model — an unlimited approval isn't a subscription, it's a standing withdrawal permission that stays valid until someone remembers to revoke it.

A third model uses smart accounts or session keys: the customer authorizes a scoped, on-chain rule — $29 USDC every 30 days, capped at 12 payments, payable only to one recipient. This is the closest real analogue to a card mandate, but support depends entirely on the customer's wallet and account-abstraction stack, and it adds smart-contract surface area your team has to audit and operate.

For most products, a hybrid is the honest answer: push-based renewal requests by default, with an opt-in, tightly scoped authorization for customers on compatible smart wallets. Don't design as if every connected wallet supports the same recurring behavior, because they don't.


Model subscriptions as state, not a cron job

A cron task that fires a charge once a month isn't billing infrastructure. It can't explain what happened when a payment is late, underpaid, or confirmed after your invoice deadline — it just fires again next month and hopes.

Split your subscription record into commercial state and payment state, and don't let the two blur together. Commercial state answers whether the customer has an active plan and what they owe. Payment state answers what actually happened to a specific charge — and that state comes straight from Klappay, not from your own guess:

Your invoice: scheduled -> charge_created -> paid
|
v
expired / underpaid (needs review)
Klappay charge: pending -> confirmed
|
v
expired / underpaid / partially_paid
Settlement: pending -> completed / failed

At minimum, store the subscription ID, the customer's wallet, the network, your pricing rule, the billing interval, the charge ID for each attempt, and an idempotencyKey per billing attempt so a retried request can never double-charge. Use externalRef on the charge to carry your own subscription or invoice ID straight through — Klappay echoes it back on the charge and on every webhook event, so you're never reconciling by amount and timestamp alone.

For allowance- or mandate-based collection, add states of your own for authorization creation, authorization expiry, and revocation. These are product states your customers need visibility into and your support team needs an audit trail for — not edge cases you handle later.


Use deterministic addresses so attribution isn't a guessing game

A recurring system has to reliably match money to an invoice, every time, without asking the customer to paste a transaction hash. A shared deposit address plus a memo field is fragile on EVM chains, because a plain USDC transfer doesn't carry an off-chain invoice ID with it.

Klappay solves this by predicting a unique payment address for every charge — deterministically derived from the charge's own ID, before it's even funded. When USDC lands there, the address itself tells you which invoice got paid; there's no separate matching step and nothing to reconcile by hand. The mechanics are worth understanding if you're going to lean on this for every renewal in your billing cycle.


Events are your billing control plane

Polling an endpoint every few seconds is fine for a prototype. It's not a foundation for entitlement, accounting, or customer notifications at renewal scale.

Consume webhooks for the durable path — provisioning access, issuing receipts, updating invoices — and use server-sent events when a checkout is actually open and needs to reflect a status change immediately. Verify every webhook signature, persist the delivery ID, and process events idempotently:

const event = klap.webhooks.constructEvent(
request.rawBody,
request.headers['x-klappay-signature'],
process.env.KLAP_WEBHOOK_SECRET,
)
if (event.event === 'charge.confirmed') {
await markInvoicePaidOnce(event.data.id, event.id)
await extendSubscriptionAccess(event.data.externalRef)
}

event.data is the full charge, so event.data.id is the charge itself and event.data.externalRef is the subscription ID you set when you created it — there's no separate lookup required. event.id is the webhook delivery ID, and it's what makes markInvoicePaidOnce mean what its name says: a retried delivery of the same charge.confirmed event should never extend access twice or double-issue a receipt.

Don't stop at charge.confirmed either. charge.confirmed tells you a qualifying transfer landed on-chain. charge.settled tells you the payout to your own wallet actually completed — a separate, independently tracked step (settlementStatus), not a formality. A stale, late-arriving charge.partially_paid event should never roll a charge backward if a charge.confirmed event already advanced it.


Price in dollars, settle in USDC, define the exceptions

USDC tracks the dollar, which makes subscription pricing simpler than it would be with a volatile asset — but it doesn't eliminate billing decisions. On Klappay, a charge's amount is always denominated in USD, and acceptedPayments is the separate list of which USDC (or USDT) network you'll take it on. For most USDC-priced SaaS products, that fixed-USD, multi-network-acceptance shape is exactly what you want: predictable amounts, no price-feed dependency of your own to maintain.

Network fees need an equally direct policy. On a plain wallet flow, the customer's wallet covers gas. In a sponsored smart-account flow, your platform might cover it to improve conversion — and Klappay's feePayer setting lets you decide who bears Klappay's own processing fee independently of that gas question. Sponsorship can make a $5 subscription practical, but it turns gas into a forecastable operating cost and opens a new door for abuse. Neither choice is universally correct; both need an explicit answer before you ship.

Also decide, ahead of time, what happens to partial payments, late payments, and payments sent on the wrong network. A charge that comes in underpaid should land in review, not get waved through as confirmed because the amount was close enough. Automation should be strict exactly where money is changing hands.


Custody is not an implementation shortcut

Plenty of crypto billing products solve their own operational complexity by pooling customer funds into a processor-controlled wallet, updating an internal balance, and paying merchants out later. That's easier for the processor to build. It's not better settlement architecture for you.

Klappay detects and routes payments without ever taking custody of merchant or customer funds — the transfer reaches your recipients directly, and for a marketplace or revenue-share subscription, native recipient splits can route each renewal to the right participants without ever passing through a pooled balance you'd have to reconcile and redistribute later.

That doesn't remove your responsibilities — you still protect API credentials, verify webhook signatures, and reconcile on-chain records with your own database. But it keeps ownership legible: the funds are where the transaction says they are, not where a dashboard claims they are.


Build the renewal path before the checkout path

Teams tend to start with a polished checkout screen and postpone the hard parts: retries, expired authorizations, cancellation timing, and access revocation. Subscription revenue is made or lost in those paths, not in the first checkout.

Define your renewal state machine and your customer-facing authorization terms first. Then build deterministic charge creation, idempotent event handling, and a way to test all of it without spending real funds — Klappay's sandbox can simulate a confirmed, underpaid, expired, or failed-settlement charge on demand, so you can exercise every renewal edge case before a real customer hits one. Only after that should you spend time polishing the wallet prompt.

A recurring payment system earns trust by being boring under failure. Bound the authorization, settle directly, and make every status transition inspectable — that gives your customers real control over their wallets, and gives your team a billing system they can actually defend. Start at klappay.com/developers when you're ready to build it.