BchainPay logoBchainPay
EngineeringSubscriptionsERC-20BillingPayments

Recurring crypto payments: on-chain subscription billing design

Build subscription billing with crypto using ERC-20 allowances, EIP-2612 permit approvals, idempotent charge cycles, and merchant-grade dunning logic.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for Recurring crypto payments: on-chain subscription billing design

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/subscriptions

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

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading