Subchapter 4.8
references/base-account/subscriptions.mdMarkdown8 KBView on GitHub
Recurring payments use Spend Permissions — an onchain primitive that lets a user grant revocable spending rights to your app. The user approves once, and your backend charges periodically without further user interaction.
Key properties:
Client (browser) Server (Node.js)
───────────────── ────────────────
subscribe() ──────────────────────> Store subscription ID
↓
getStatus() → check if chargeable
↓
charge() → execute periodic charge
↓
revoke() → cancel when neededThe server uses a CDP (Coinbase Developer Platform) smart wallet to act as the subscription owner (the entity authorized to spend).
CDP_API_KEY_ID=your-api-key-id
CDP_API_KEY_SECRET=your-api-key-secret
CDP_WALLET_SECRET=your-wallet-secret
PAYMASTER_URL=https://your-paymaster.xyz # optional, for gasless transactionsGet these from Coinbase Developer Platform (opens in a new tab).
import { base } from '@base-org/account/node';
const wallet = await base.subscription.getOrCreateSubscriptionOwnerWallet({
walletName: 'my-app-subscriptions',
});
// wallet.address → share this with the frontend as subscriptionOwner
// wallet.walletName → must match across charge() and revoke() calls| Parameter | Type | Required | Description |
|---|---|---|---|
walletName | string | No | Wallet identifier (default: "subscription owner") |
cdpApiKeyId | string | No | Falls back to CDP_API_KEY_ID env var |
cdpApiKeySecret | string | No | Falls back to CDP_API_KEY_SECRET env var |
cdpWalletSecret | string | No | Falls back to CDP_WALLET_SECRET env var |
Returns: { address, walletName, eoaAddress }
This is idempotent — the same walletName always returns the same wallet. The address is the CDP smart wallet address (safe to share publicly as subscriptionOwner).
Never expose CDP credentials client-side. Only the wallet address is public.
import { base } from '@base-org/account';
const subscription = await base.subscription.subscribe({
recurringCharge: '9.99',
subscriptionOwner: '0xYourCDPWalletAddress',
periodInDays: 30,
testnet: false,
});
// subscription.id → store this as the subscription identifier| Parameter | Type | Required | Description |
|---|---|---|---|
recurringCharge | string | Yes | USDC amount per period (max 6 decimals) |
subscriptionOwner | string | Yes | Your CDP wallet address |
periodInDays | number | No | Charge period in days (default: 30) |
testnet | boolean | No | Use testnet (default: false) |
requireBalance | boolean | No | Check payer balance first (default: true) |
Returns SubscriptionResult:
| Field | Type | Description |
|---|---|---|
id | string | Permission hash (subscription identifier) |
subscriptionOwner | string | Your app’s wallet address |
subscriptionPayer | string | The user’s wallet address |
recurringCharge | string | Amount in USD |
periodInDays | number | Period length |
import { base } from '@base-org/account';
const status = await base.subscription.getStatus({
id: subscriptionId,
testnet: false,
});| Parameter | Type | Required |
|---|---|---|
id | string | Yes |
testnet | boolean | No |
Returns SubscriptionStatus:
| Field | Type | Description |
|---|---|---|
isSubscribed | boolean | Whether subscription is active |
recurringCharge | string | Charge amount |
remainingChargeInPeriod | string | How much can still be charged this period |
currentPeriodStart | Date | — |
nextPeriodStart | Date | — |
periodInDays | number | — |
Check before charging:
const status = await base.subscription.getStatus({ id: subscriptionId });
if (status.isSubscribed && parseFloat(status.remainingChargeInPeriod!) > 0) {
// safe to charge
}import { base } from '@base-org/account/node';
const result = await base.subscription.charge({
id: subscriptionId,
amount: 'max-remaining-charge',
paymasterUrl: process.env.PAYMASTER_URL,
testnet: false,
});| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Subscription ID |
amount | string | 'max-remaining-charge' | Yes | USDC amount or 'max-remaining-charge' |
paymasterUrl | string | No | For gasless transactions |
recipient | string | No | Send USDC to a different address (default: stays in CDP wallet) |
testnet | boolean | No | Default: false |
walletName | string | No | Must match the wallet used in setup |
Returns: { success, id, subscriptionId, amount, subscriptionOwner, recipient }
charge() handles all transaction details: gas estimation, nonce management, and signing.
import { base } from '@base-org/account/node';
const result = await base.subscription.revoke({
id: subscriptionId,
paymasterUrl: process.env.PAYMASTER_URL,
testnet: false,
});| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Subscription ID |
paymasterUrl | string | No | For gasless transactions |
testnet | boolean | No | Default: false |
walletName | string | No | Must match the wallet used in setup |
Returns: { success, id, subscriptionId, subscriptionOwner }
Revoking is permanent. The user would need to create a new subscription.
For custom wallet infrastructure (not using CDP wallets), use prepareCharge and prepareRevoke to get raw call data.
import { base } from '@base-org/account';
const calls = await base.subscription.prepareCharge({
id: subscriptionId,
amount: 'max-remaining-charge',
testnet: false,
});
// calls → Array<{ to, data, value: '0x0' }>
// Execute via wallet_sendCalls or eth_sendTransactionconst call = await base.subscription.prepareRevoke({
id: subscriptionId,
testnet: false,
});
// call → { to, data, value: '0x0' }| Pattern | How | When |
|---|---|---|
| Default | Omit recipient | USDC stays in CDP wallet |
| Treasury | recipient: '0xTreasury' | Auto-transfer to treasury |
| Dynamic | Set recipient per charge | Route to different addresses based on plan type |
testnet: true in all calls (subscribe, getStatus, charge, revoke)periodInDays: 1 for faster testing cycles