Most crypto payment infrastructure is designed around one-time transfers: the merchant presents a deposit address, the customer sends funds, the gateway confirms. Subscriptions break that model entirely. The customer is not present at charge time. There is no card-on-file. Every periodic charge requires on-chain authorization, gas, and a reliable way to retry when a charge fails — and to do all of this without any reversals if something goes wrong after the fact.
This post covers the design of a pull-payment subscription engine on EVM chains: the allowance model, how EIP-2612 permits eliminate the approval round-trip, how to structure idempotent charge cycles, and what dunning logic looks like when the payment rail is a public blockchain.
Pull vs push: the fundamental model#
Card subscriptions are push-on-behalf: the issuing bank holds card credentials
and the merchant charges them remotely via the card network. Crypto
subscriptions work by pull: the subscriber approves an ERC-20 allowance
on-chain, and the gateway's hot wallet calls transferFrom at each billing
cycle.
// What the gateway executes on every billing period
IERC20(token).transferFrom(subscriberWallet, merchantVault, chargeAmount);The allowance is the authorization. If the subscriber revokes it
(approve(gateway, 0)) or the allowance is exhausted, the next charge reverts.
Unlike card chargebacks, there is no network to call: the subscriber
unilaterally withdrew authorization, and the gateway must treat that as a
hard cancel.
Two allowance patterns are in common use:
Unlimited allowance. approve(gateway, type(uint256).max). Simple, but
requires the subscriber to trust the gateway forever with their entire token
balance. Acceptable for wallets that hold only what they are about to spend;
a poor choice for any wallet holding meaningful assets.
Bounded cycle allowance. approve(gateway, amount * N_cycles). The
subscriber approves enough for a fixed number of billing cycles. When the
remaining allowance falls below one cycle's amount, the gateway requests a
top-up. The subscriber either re-approves or effectively cancels. This is the
pattern BchainPay recommends for consumer-facing subscriptions.
EIP-2612 permit: gasless first approval#
The on-chain approve call costs gas — typically $0.05–$2 on a busy L2, more
on Ethereum mainnet. Asking a new subscriber to send a separate approval
transaction before their subscription activates introduces drop-off.
EIP-2612 allows USDC, DAI, and any
ERC-20 implementing permit to authorize a spending allowance via an off-chain
signature. The gateway collects a signed permit during checkout and presents it
alongside the first transferFrom in a single transaction.
// Collecting the permit signature in the frontend
const domain = {
name: "USD Coin",
version: "2",
chainId: 8453, // Base
verifyingContract: USDC_BASE,
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
// Approve 12 months × 29 USDC; deadline = subscription anchor + 13 months
const value = BigInt(29e6) * 12n; // USDC has 6 decimals
const deadline = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 395;
const nonce = await usdc.nonces(subscriberAddress);
const sig = await signer.signTypedData(domain, types, {
owner: subscriberAddress, spender: GATEWAY_SPENDER,
value, nonce, deadline,
});
// POST { sig, deadline, value, nonce } → POST /v1/subscriptionsThe gateway stores the permit components and calls
permit(owner, spender, value, deadline, v, r, s) atomically with the first
transferFrom. The subscriber pays no gas; the gateway absorbs the cost and
recovers it through the service fee. Tokens that do not implement EIP-2612 fall
back to requiring a standard approve transaction; offer both paths in the
checkout UI and default to permit where available.
Billing anchors and the charge cycle#
Each subscription record stores an anchor_timestamp — the Unix epoch of the
first successful charge — and a period_seconds (86 400 × 30 for monthly).
The next due date is anchor + n × period_seconds for the smallest n such
that the result is in the future. This keeps billing dates stable across months
and prevents drift from retry delays.
The charge worker runs on a short interval and selects subscriptions where the next due date has passed and no charge record exists for the current period:
const due = await db.query<Subscription[]>(`
SELECT s.*
FROM subscriptions s
WHERE s.status = 'active'
AND s.next_charge_at <= now()
AND NOT EXISTS (
SELECT 1 FROM subscription_charges sc
WHERE sc.subscription_id = s.id
AND sc.period_start = s.next_charge_at
AND sc.status NOT IN ('voided')
)
LIMIT 100
FOR UPDATE SKIP LOCKED
`);
for (const sub of due) {
const key = `sub_charge:${sub.id}:${sub.next_charge_at.getTime()}`;
await chargeCycle(sub, key);
}FOR UPDATE SKIP LOCKED prevents two workers from double-charging the same
subscriber. The idempotency key is written to subscription_charges before
the on-chain call: if the process crashes between signing and broadcast, the
next run finds the existing pending record and resumes rather than creating
a duplicate.
async function chargeCycle(sub: Subscription, key: string) {
await db.query(`
INSERT INTO subscription_charges
(id, subscription_id, period_start, amount_atomic, status)
VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (id) DO NOTHING
`, [key, sub.id, sub.next_charge_at, sub.amount_atomic]);
const txHash = await wallet.sendTransaction({
to: sub.token_address,
data: erc20.encodeFunctionData("transferFrom", [
sub.subscriber_address,
sub.merchant_vault,
sub.amount_atomic,
]),
});
await db.query(`
UPDATE subscription_charges
SET tx_hash = $2, status = 'broadcast'
WHERE id = $1
`, [key, txHash]);
}Retry and dunning logic#
A transferFrom can revert for two reasons: the allowance is too small
(revoked or exhausted) or the subscriber's balance is insufficient. Both appear
as on-chain reverts; simulating before broadcasting lets you distinguish them
without spending gas on doomed transactions.
try {
await publicClient.simulateContract({
address: sub.token_address,
abi: erc20Abi,
functionName: "transferFrom",
args: [sub.subscriber_address, sub.merchant_vault, sub.amount_atomic],
account: gatewayAddress,
});
} catch (err) {
const reason = parseRevertReason(err);
// "ERC20: insufficient allowance" → subscriber revoked / allowance exhausted
// "ERC20: transfer amount exceeds balance" → subscriber has insufficient funds
}Simulation passing then broadcast reverting is rare (balance drained in the same block) but possible; treat it as a transient failure and retry as normal.
Dunning schedule:
| Attempt | Delay after failure | Action on failure |
|---|---|---|
| 1 | Immediate | subscription.charge_failed webhook |
| 2 | +3 days | Retry charge |
| 3 | +7 days | Retry charge |
| 4 | +14 days | Final retry |
| — | +1 day after 4th failure | subscription.suspended — access revoked |
| — | +30 days in suspended | subscription.cancelled — record archived |
The grace period keeps access active while subscribers top up their wallet balance or re-approve the allowance. The gateway should surface a re-approve flow proactively when the remaining allowance drops below two billing cycles, rather than waiting for the first failed charge to notify the subscriber.
Webhook shape for a failed charge:
{
"event": "subscription.charge_failed",
"data": {
"subscription_id": "sub_01HZABC…",
"period_start": "2026-05-27T00:00:00Z",
"amount": "29.00",
"currency": "USDC",
"chain": "base",
"failure_reason": "insufficient_allowance",
"retry_at": "2026-05-30T00:00:00Z",
"attempts_remaining": 3
}
}Subscription API design#
Creating a subscription via the BchainPay API:
curl -X POST https://api.bchainpay.com/v1/subscriptions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subscriber_address": "0xA1b2C3d4…",
"amount": "29.00",
"currency": "USDC",
"chain": "base",
"interval": "monthly",
"trial_days": 7,
"permit": {
"deadline": 1782000000,
"value": "348000000",
"v": 27,
"r": "0xabc123…",
"s": "0xdef456…"
},
"metadata": { "plan": "pro", "user_id": "usr_7789" }
}'Response:
{
"id": "sub_01HZABC…",
"status": "trialing",
"subscriber_address": "0xA1b2C3d4…",
"amount": "29.00",
"currency": "USDC",
"chain": "base",
"interval": "monthly",
"trial_ends_at": "2026-05-04T00:00:00Z",
"next_charge_at": "2026-05-04T00:00:00Z",
"allowance_remaining": "348.00",
"created_at": "2026-04-27T12:00:00Z"
}trial_days delays the first charge by setting
next_charge_at = created_at + trial_seconds. The permit is stored but not
exercised until the trial ends. If the subscriber cancels in-trial, the permit
is never used. To cancel, revoke the ERC-20 allowance on-chain:
approve(gateway, 0). The next charge simulation fails; the gateway detects
the revocation and moves the subscription to cancelled. Merchants who want an
explicit server-side cancel can also call DELETE /v1/subscriptions/{id}.
Solana: delegateAmount instead of allowances#
Solana's SPL Token program uses a delegate and delegatedAmount rather than
the ERC-20 allowance model. The subscriber calls the SPL Token approve
instruction to set the gateway as delegate for a bounded amount. The gateway
then calls transferChecked using that delegation:
await transferChecked(
connection,
gatewayKeypair, // fee payer
subscriberTokenAccount,
mintPublicKey,
merchantTokenAccount,
gatewayKeypair.publicKey, // delegate authority
BigInt(29_000_000), // 29 USDC, 6 decimals
6,
);transferChecked enforces mint and decimal count, preventing decimal confusion
attacks. Unlike EVM, SPL Token v1 has no permit equivalent — the subscriber
must send an on-chain approval transaction. To absorb this friction, set the
gateway's keypair as fee payer for the subscriber's approval transaction and
recover the lamport cost from the first charge event.
Key takeaways#
- Pull payments require on-chain authorization. The ERC-20 allowance is the subscription credential. Treat its presence and remaining balance exactly as you would a stored card — verify it before every charge cycle.
- EIP-2612 permits eliminate the approval round-trip for USDC, DAI, and
other permit-supporting tokens. Collect the signature at checkout and present
it atomically with the first
transferFrom. - Anchor billing dates to the first charge, not the last. Deriving next-charge from last-charge accumulates drift; retry delays shift all future billing dates.
- Write the charge record before broadcasting. An idempotency key inserted before the on-chain call prevents double charges on worker restart.
- Simulate before broadcasting. A failed simulation costs no gas and distinguishes revoked allowances from insufficient balances, letting you route each failure to the correct dunning path.
- Notify before the allowance runs out, not after. Surface the re-approve
flow when
allowance_remaining < 2 × amount. Waiting for the first failed charge guarantees a service interruption the subscriber did not expect.