diff --git a/docs/base-account/guides/accept-recurring-payments.mdx b/docs/base-account/guides/accept-recurring-payments.mdx
index b07ab082c..f4580e74b 100644
--- a/docs/base-account/guides/accept-recurring-payments.mdx
+++ b/docs/base-account/guides/accept-recurring-payments.mdx
@@ -106,8 +106,9 @@ Get CDP credentials from [CDP Portal](https://portal.cdp.coinbase.com/projects/a
To accept recurring payments, you need:
1. CDP credentials (API key ID, secret, and wallet secret)
2. Backend infrastructure (Node.js) to execute charges securely
-3. Database to store and manage subscription IDs
+3. Database to store subscription IDs **bound to the authenticated user** (and their payer address)
4. Never expose CDP credentials in client-side code
+5. Never charge an arbitrary subscription `id` from the browser alone — look up the ID from your database, and pass `expectedPayer` to `charge()` / `revoke()`
### Setup: Create Your Subscription Owner Wallet
@@ -256,7 +257,11 @@ import { base } from '@base-org/account/node';
// Requires: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET env vars
// Recommended: PAYMASTER_URL for gasless transactions
-async function chargeSubscription(subscriptionId: string, recipientAddress?: string) {
+async function chargeSubscription(
+ subscriptionId: string,
+ payerAddress: string,
+ recipientAddress?: string
+) {
try {
// 1. Check subscription status
const status = await base.subscription.getStatus({
@@ -277,10 +282,12 @@ async function chargeSubscription(subscriptionId: string, recipientAddress?: str
}
// 2. Charge the subscription - CDP handles everything automatically
+ // Use the server-stored id + payerAddress from your database (not browser input alone)
// Using paymaster for gasless transactions (recommended)
const result = await base.subscription.charge({
id: subscriptionId,
amount: 'max-remaining-charge',
+ expectedPayer: payerAddress,
paymasterUrl: process.env.PAYMASTER_URL, // Optional: for gasless transactions
recipient: recipientAddress, // Optional: send USDC to specific address
testnet: false
@@ -313,11 +320,16 @@ Cancel subscriptions programmatically from your backend:
```typescript revokeSubscription.ts expandable
import { base } from '@base-org/account/node';
-async function revokeSubscription(subscriptionId: string, reason: string) {
+async function revokeSubscription(
+ subscriptionId: string,
+ payerAddress: string,
+ reason: string
+) {
try {
// Revoke the subscription with paymaster for gasless transactions
const result = await base.subscription.revoke({
id: subscriptionId,
+ expectedPayer: payerAddress,
paymasterUrl: process.env.PAYMASTER_URL, // Optional: for gasless transactions
testnet: false
});
@@ -338,12 +350,12 @@ async function revokeSubscription(subscriptionId: string, reason: string) {
}
// Usage examples
-async function handleUserCancellation(subscriptionId: string) {
- return await revokeSubscription(subscriptionId, 'user_requested');
+async function handleUserCancellation(subscriptionId: string, payerAddress: string) {
+ return await revokeSubscription(subscriptionId, payerAddress, 'user_requested');
}
-async function handlePolicyViolation(subscriptionId: string) {
- return await revokeSubscription(subscriptionId, 'policy_violation');
+async function handlePolicyViolation(subscriptionId: string, payerAddress: string) {
+ return await revokeSubscription(subscriptionId, payerAddress, 'policy_violation');
}
```
@@ -479,7 +491,9 @@ import { base } from '@base-org/account';
const chargeCalls = await base.subscription.prepareCharge({
id: subscriptionId,
amount: 'max-remaining-charge',
- testnet: false
+ testnet: false,
+ expectedSpender: subscriptionOwner,
+ expectedPayer: payerAddress,
});
// Execute with your own wallet infrastructure
@@ -498,7 +512,9 @@ import { base } from '@base-org/account';
// Prepare revoke call data
const revokeCall = await base.subscription.prepareRevoke({
id: subscriptionId,
- testnet: false
+ testnet: false,
+ expectedSpender: subscriptionOwner,
+ expectedPayer: payerAddress,
});
// Execute with your own wallet infrastructure
diff --git a/docs/base-account/reference/base-pay/charge.mdx b/docs/base-account/reference/base-pay/charge.mdx
index fc4f68bb8..124a9ead2 100644
--- a/docs/base-account/reference/base-pay/charge.mdx
+++ b/docs/base-account/reference/base-pay/charge.mdx
@@ -13,12 +13,16 @@ Defined in the [Base Account SDK](https://github.com/base/account-sdk)
The `charge` function executes subscription charges automatically from your backend. It uses a CDP smart wallet as the subscription owner, handling all transaction details including wallet management, transaction signing, and optional gas sponsorship. **No manual transaction management required.**
+
+**Treat subscription IDs as capability handles, not proof of ownership.** Store the ID on your backend against the authenticated user when they subscribe. Do not charge an arbitrary `id` supplied by the browser alone. Pass `expectedPayer` (the subscriber's wallet from your session/database) so the SDK rejects subscriptions that do not belong to that user. `charge()` also requires the permission spender to match your CDP smart wallet.
+
+
## How It Works
When you call `charge()`, the function:
1. Initializes a CDP client with your credentials
2. Retrieves the existing smart wallet (subscription owner)
-3. Prepares the charge transaction calls
+3. Prepares the charge transaction calls (and verifies the permission spender matches that wallet)
4. Executes the charge using the smart wallet
5. Optionally uses a paymaster for gas sponsorship
6. Returns the transaction hash
@@ -26,7 +30,7 @@ When you call `charge()`, the function:
## Parameters
-The subscription ID (permission hash) returned from `subscribe()`.
+The subscription ID (permission hash) returned from `subscribe()`. Prefer a server-stored ID bound to the authenticated user.
**Pattern:** `^0x[0-9a-fA-F]{64}$`
@@ -69,6 +73,18 @@ Optional recipient address to receive the charged USDC. If not provided, USDC st
**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+Optional subscriber wallet address. When set, the subscription's payer must match this address or `charge()` throws. Use the authenticated user's wallet from your session or database.
+
+**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+
+
+Optional. Must match your CDP smart wallet if provided. `charge()` already binds the permission spender to the executing CDP wallet automatically.
+
+**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+
## Returns
@@ -119,9 +135,11 @@ Or pass directly as parameters (see examples below).
import { base } from '@base-org/account/node';
// Requires: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET env vars
+// Use a server-stored subscription id + the subscriber wallet from your DB/session
const result = await base.subscription.charge({
- id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984',
- amount: '9.99'
+ id: storedSubscriptionId,
+ amount: '9.99',
+ expectedPayer: authenticatedUserAddress,
});
console.log(`Charged subscription: ${result.id}`);
@@ -286,6 +304,15 @@ Wallet "subscription owner" does not exist
**Solution**: The CDP wallet hasn't been created yet. First call [`getOrCreateSubscriptionOwnerWallet`](/base-account/reference/base-pay/getOrCreateSubscriptionOwnerWallet) to set up the wallet.
+
+
+```
+Subscription spender 0x... does not match expected spender 0x...
+Subscription payer 0x... does not match expected payer 0x...
+```
+
+**Solution**: Confirm you are charging with the CDP wallet that was used as `subscriptionOwner` at subscribe time, and that `expectedPayer` matches the subscriber wallet stored for that user.
+
## Usage Pattern
@@ -320,10 +347,11 @@ async function chargeActiveSubscriptions() {
continue;
}
- // Execute charge
+ // Execute charge — pass expectedPayer from your DB record
const result = await base.subscription.charge({
id: subscription.subscriptionId,
amount: 'max-remaining-charge',
+ expectedPayer: subscription.payerAddress,
testnet: false
});
diff --git a/docs/base-account/reference/base-pay/prepareCharge.mdx b/docs/base-account/reference/base-pay/prepareCharge.mdx
index 1a956cd8a..8f289b2a6 100644
--- a/docs/base-account/reference/base-pay/prepareCharge.mdx
+++ b/docs/base-account/reference/base-pay/prepareCharge.mdx
@@ -13,6 +13,10 @@ Defined in the [Base Account SDK](https://github.com/base/account-sdk)
The `prepareCharge` function prepares the necessary transaction calls to charge a subscription. It returns the array of call data objects to execute the charge through `wallet_sendCalls` or `eth_sendTransaction`. This gives you programmatic control over when and how to execute subscription charges.
+
+A subscription ID is not an authorization token. Store IDs server-side against the authenticated user, and pass `expectedSpender` / `expectedPayer` when the ID may come from an untrusted client. Execute the returned calls from the subscription owner (`from` must be the spender).
+
+
## When to Use This
Use `prepareCharge` only if you need:
@@ -26,7 +30,7 @@ For standard backend subscription management, use [`charge()`](/base-account/ref
## Parameters
-The subscription ID (permission hash) returned from subscribe().
+The subscription ID (permission hash) returned from subscribe(). Prefer a server-stored ID bound to the authenticated user.
**Pattern:** `^0x[0-9a-fA-F]{64}$`
@@ -39,6 +43,18 @@ Amount to charge (e.g., "10.50") or 'max-remaining-charge' for the full remainin
Must match the testnet setting used in the original subscribe call. Default: false
+
+Optional address that must match the subscription spender (subscription owner). Pass your app's spender wallet before executing the calls.
+
+**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+
+
+Optional address that must match the subscription payer (subscriber). Pass the authenticated user's wallet from your session or database.
+
+**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+
## Returns
@@ -80,9 +96,11 @@ const walletClient = createWalletClient({
// Prepare to charge a specific amount
const chargeCalls = await base.subscription.prepareCharge({
- id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984',
+ id: storedSubscriptionId,
amount: '9.99',
- testnet: false
+ testnet: false,
+ expectedSpender: account.address,
+ expectedPayer: authenticatedUserAddress,
});
// Execute each charge call
diff --git a/docs/base-account/reference/base-pay/prepareRevoke.mdx b/docs/base-account/reference/base-pay/prepareRevoke.mdx
index 69a786ee5..a48505a25 100644
--- a/docs/base-account/reference/base-pay/prepareRevoke.mdx
+++ b/docs/base-account/reference/base-pay/prepareRevoke.mdx
@@ -13,6 +13,10 @@ Defined in the [Base Account SDK](https://github.com/base/account-sdk)
The `prepareRevoke` function prepares the necessary transaction call to revoke a subscription. It returns call data that you can execute through your own wallet infrastructure using `wallet_sendCalls` or `eth_sendTransaction`.
+
+A subscription ID is not an authorization token. Prefer server-stored IDs bound to the authenticated user, and pass `expectedSpender` / `expectedPayer` when the ID may be untrusted.
+
+
## When to Use This
Use `prepareRevoke` only if you need:
@@ -25,7 +29,7 @@ For standard backend subscription management, use [`revoke()`](/base-account/ref
## Parameters
-The subscription ID (permission hash) returned from `subscribe()`.
+The subscription ID (permission hash) returned from `subscribe()`. Prefer a server-stored ID bound to the authenticated user.
**Pattern:** `^0x[0-9a-fA-F]{64}$`
@@ -34,6 +38,18 @@ The subscription ID (permission hash) returned from `subscribe()`.
Must match the testnet setting used in the original subscribe call. Default: false
+
+Optional address that must match the subscription spender (subscription owner).
+
+**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+
+
+Optional address that must match the subscription payer (subscriber).
+
+**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+
## Returns
diff --git a/docs/base-account/reference/base-pay/revoke.mdx b/docs/base-account/reference/base-pay/revoke.mdx
index 5e793fd48..c7c13af91 100644
--- a/docs/base-account/reference/base-pay/revoke.mdx
+++ b/docs/base-account/reference/base-pay/revoke.mdx
@@ -13,12 +13,16 @@ Defined in the [Base Account SDK](https://github.com/base/account-sdk)
The `revoke` function cancels subscriptions automatically from your backend. It uses a CDP smart wallet as the subscription owner to execute the revocation transaction, handling all details including wallet management, transaction signing, and optional gas sponsorship.
+
+**Treat subscription IDs as capability handles, not proof of ownership.** Prefer a server-stored ID bound to the authenticated user. Pass `expectedPayer` so the SDK rejects revocations for subscriptions that do not belong to that user. `revoke()` also requires the permission spender to match your CDP smart wallet.
+
+
## How It Works
When you call `revoke()`, the function:
1. Initializes a CDP client with your credentials
2. Retrieves the existing smart wallet (subscription owner)
-3. Prepares the revoke transaction call
+3. Prepares the revoke transaction call (and verifies the permission spender matches that wallet)
4. Executes the revocation using the smart wallet
5. Optionally uses a paymaster for gas sponsorship
6. Returns the transaction hash
@@ -26,7 +30,7 @@ When you call `revoke()`, the function:
## Parameters
-The subscription ID (permission hash) returned from `subscribe()`.
+The subscription ID (permission hash) returned from `subscribe()`. Prefer a server-stored ID bound to the authenticated user.
**Pattern:** `^0x[0-9a-fA-F]{64}$`
@@ -59,6 +63,18 @@ Optional custom wallet name for the CDP smart wallet. Default: "subscription own
Paymaster URL for transaction sponsorship (gasless transactions). Falls back to `PAYMASTER_URL` environment variable.
+
+Optional subscriber wallet address. When set, the subscription's payer must match this address or `revoke()` throws.
+
+**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+
+
+Optional. Must match your CDP smart wallet if provided. `revoke()` already binds the permission spender to the executing CDP wallet automatically.
+
+**Pattern:** `^0x[0-9a-fA-F]{40}$`
+
+
## Returns
@@ -102,7 +118,8 @@ import { base } from '@base-org/account/node';
// Requires: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET env vars
const result = await base.subscription.revoke({
- id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984'
+ id: storedSubscriptionId,
+ expectedPayer: authenticatedUserAddress,
});
console.log(`Revoked subscription: ${result.id}`);
diff --git a/docs/base-account/reference/base-pay/subscriptions-overview.mdx b/docs/base-account/reference/base-pay/subscriptions-overview.mdx
index bb3ac6fef..dd46ab0c6 100644
--- a/docs/base-account/reference/base-pay/subscriptions-overview.mdx
+++ b/docs/base-account/reference/base-pay/subscriptions-overview.mdx
@@ -98,6 +98,10 @@ interface ChargeOptions {
paymasterUrl?: string;
recipient?: Address;
testnet?: boolean;
+ /** Subscriber wallet; rejects if the permission payer does not match */
+ expectedPayer?: Address;
+ /** Optional; charge() already binds spender to the CDP smart wallet */
+ expectedSpender?: Address;
}
// Charge result
@@ -112,6 +116,8 @@ interface RevokeOptions {
id: string;
paymasterUrl?: string;
testnet?: boolean;
+ expectedPayer?: Address;
+ expectedSpender?: Address;
}
// Revoke result
@@ -135,6 +141,8 @@ interface PrepareChargeOptions {
amount: string | 'max-remaining-charge';
recipient?: Address;
testnet?: boolean;
+ expectedSpender?: Address;
+ expectedPayer?: Address;
}
type PrepareChargeResult = Array<{
@@ -147,6 +155,8 @@ type PrepareChargeResult = Array<{
interface PrepareRevokeOptions {
id: string;
testnet?: boolean;
+ expectedSpender?: Address;
+ expectedPayer?: Address;
}
type PrepareRevokeResult = {