> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cdp.coinbase.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Recurring Payments

> Accept recurring payments and merchant-initiated transactions from a customer's Coinbase account or self-custody wallet after a single approval. Built for subscriptions, usage-based billing, and repeat purchases in stablecoins.

Recurring payments, also known as **merchant-initiated transactions (MITs)**, let a customer approve once, then let you charge them later without asking again. Each charge is a normal payment session, so capture, void, refund, webhooks, and settlement work exactly as they do for every other Payment Acceptance payment. Customers can stop future charges at any time.

## What you can build

> [card] Subscriptions
>
> Bill monthly or annual plans without sending the customer back to checkout every cycle.

> [card] Usage-based billing
>
> Bill for metered usage at the end of each billing period, for any amount within your limits.

> [card] Auto top-ups
>
> Refill a prepaid balance or credits automatically when it runs low.

> [card] One-click repeat purchases
>
> Let returning customers pay without going through checkout again.

## Choose a source

Customers can pay from their Coinbase account or from a self-custody wallet. You can offer both.

| | Coinbase account | Self-custody wallet |
| - | - | - |
| **Customer needs** | A Coinbase account with USDC | Their own wallet with stablecoins |
| **One-time setup** | Link the account through OAuth | Approve a mandate |
| **Each charge** | Start a challenge with `initiator: "merchant"`, then authorize with the customer's OAuth token. Only the first charge needs the customer present. | Authorize with the `mandateId` |
| **Spending limits** | The customer's Coinbase daily limits | The mandate policy you set, checked by Coinbase |
| **Balance check** | Coinbase balance API, production only | Onchain balance, before each charge |
| **Customer stops it** | Disconnects your app in Coinbase | Revokes the mandate |

## Charge a Coinbase account

Recurring charges from a Coinbase account use the [Pay with Coinbase](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-coinbase.md) flow. After the first charge, your billing job runs it without the customer.

1. **Link the account and take the first charge while the customer is present.** Start the first charge's challenge with `initiator: "merchant"`, not `customer`. See [Link the customer's Coinbase account](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-coinbase.md#1-link-the-customers-coinbase-account) and the [scenario table](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-coinbase.md#charge-without-the-customer-present).
2. **Refresh the access token only when it has expired or is about to.** Check `expires_at` before each charge. Each refresh invalidates the previous refresh token, and sending an invalidated refresh token ends the link with `invalid_grant`. Run one refresh at a time per customer, and save the new refresh token before you use the new access token. See [Refresh tokens](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-coinbase.md#refresh-tokens).
3. **Check `available_to_send`** before you create the session. See [Check the customer's USDC balance](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-coinbase.md#2-check-the-customers-usdc-balance).
4. **Authorize off-session** with `initiator: "merchant"` and `enforce2fa: false`. See [Charge without the customer present](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-coinbase.md#charge-without-the-customer-present).
5. **Stop billing when the link ends.** A `401` on authorization can mean the access token expired, so refresh once and retry. The link has ended when the refresh returns `invalid_grant`, or when you receive `oauth.token.revoked` or `identity.user.deleted`. After `invalid_grant` or `oauth.token.revoked`, the customer must link again.

## Charge a self-custody wallet

The customer approves a **mandate** once: their revocable consent for you to pull payments from their wallet, within limits you set. Funds stay in their wallet until you charge. Approving and paying are gasless. Customers can approve from Base, Ethereum, Arbitrum, Optimism, Polygon, or Solana. Solana uses the network name `solana` in every environment, including sandbox.

```mermaid theme={null}
sequenceDiagram
    participant M as Your platform
    participant C as Coinbase
    participant W as Customer wallet

    M->>C: Create a mandate with spending limits
    C-->>M: Mandate with a hosted approval url
    M->>W: Redirect the customer, or embed the approval in your app
    W->>C: Customer signs a one-time, gasless approval
    C-->>M: Webhook: acceptance.mandate.approval_succeeded
    Note over M,W: Later, with the customer away
    M->>C: Create a payment session and authorize it with the mandate
    C->>W: Pull the amount from the customer's wallet
    C-->>M: Webhook: acceptance.payment_session.authorization_succeeded
```

### 1. Create a mandate

Create one mandate per customer relationship, such as one per subscription. Set spending limits that fit your billing model.

```bash theme={null}
cdp api -X POST /mandates -e sandbox \
  asset=usdc \
  policy.maxPerAuthorization=50.00 \
  policy.maxPerPeriod.amount=100.00 \
  policy.maxPerPeriod.period=month \
  metadata.customer_id=cust_12345
```

| Field | Description |
| - | - |
| `asset` | The stablecoin the spending limits are denominated in, such as `usdc`. |
| `policy` | Spending limits. Optional. See [Mandate spending limits](#mandate-spending-limits). |
| `expiresAt` | Optional date after which you can no longer authorize payments with the mandate. Omit for no expiry. |
| `metadata` | Up to 10 key-value pairs, such as your customer or subscription ID. |

> [accordion] Example response
>
> ```json theme={null}
> {
>   "mandateId": "mandate_82c879c1-84e1-44ed-a8c2-1ac239cf09ad",
>   "asset": "usdc",
>   "policy": {
>     "maxPerAuthorization": "50.00",
>     "maxPerPeriod": {
>       "amount": "100.00",
>       "period": "month"
>     }
>   },
>   "status": "created",
>   "url": "https://payments.coinbase.com/mandates/mandate_82c879c1-84e1-44ed-a8c2-1ac239cf09ad",
>   "metadata": {
>     "customer_id": "cust_12345"
>   },
>   "createdAt": "2026-06-15T12:00:00.000Z",
>   "updatedAt": "2026-06-15T12:00:00.000Z"
> }
> ```

Coinbase generates the `mandateId`. You can't set it yourself, so save it with your customer record. You'll need it to authorize every payment. To attach your own identifiers, such as a customer or subscription ID, use `metadata`.

```bash theme={null}
export MANDATE_ID="mandate_82c879c1-84e1-44ed-a8c2-1ac239cf09ad"
```

The mandate starts in `created` status. You can't authorize payments with it until the customer approves it.

### 2. Get the customer's approval

The customer approves the mandate once from their self-custody wallet, for example during signup or checkout.

Redirect the customer to the mandate's `url`, or embed the approval in your page by calling `render({ mandateId })` on the `<coinbase-payment>` web component. Coinbase connects the wallet, picks the best-funded network, and collects the signature. See [Hosted Checkout](https://docs.cdp.coinbase.com/payments/payment-acceptance/hosted-checkout.md#mandate-approval-and-revocation) for setup.

The approval returns in `pending` status and confirms onchain shortly after. Wait for the `acceptance.mandate.approval_succeeded` webhook before you authorize a payment. At that point the mandate moves to `approval_succeeded` and its `source` shows the wallet address and network the customer approved from. If approval fails, the mandate moves to `approval_failed` and the customer can try again.

> [accordion] Build your own approval UI
>
> You only need these endpoints if you're not using the hosted page or embedded component. They don't use your API key, because the customer's wallet signature is the proof of consent, so you can call them from your frontend. The flow mirrors [wallet authorization](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-wallet.md).
>
> 1. Get approval options for one to five of the customer's wallet addresses:
>
>    ```bash theme={null}
>    curl "https://sandbox.cdp.coinbase.com/platform/v2/mandates/$MANDATE_ID/approvals/wallet/options?addresses=0xAbC1234567890aBcDeF1234567890AbCdEf123456"
>    ```
>
>    Coinbase returns at most one option, choosing the address and network with the highest stablecoin balance, including Solana. Pass `network` only when the customer must approve from one network. The Solana network name is `solana` in every environment, including sandbox. Addresses that can't be used appear in `ineligibleAddresses` with a reason, such as `insufficient_funds`.
>
> 2. Sign the payload for the option's `type`:
>
>    | `type` | How the customer signs | What you submit as `signature` |
>    | - | - | - |
>    | `eip2612` | Pass `data` to `eth_signTypedData_v4` | `0x`-prefixed hex signature |
>    | `solana_subscription` | Sign the transaction. See [Solana signatures](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-wallet.md#solana-signatures). | Base58-encoded 64-byte signature |
>
> 3. Submit the signature:
>
>    ```bash theme={null}
>    curl -X POST "https://sandbox.cdp.coinbase.com/platform/v2/mandates/$MANDATE_ID/approvals/wallet" \
>      -H "Content-Type: application/json" \
>      -d '{
>        "optionId": "opt_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
>        "signedPayloads": [
>          { "payloadId": "payload_af2937b0-...", "signature": "0xabcdef..." }
>        ]
>      }'
>    ```
>
>    The example signature is an EVM hex signature. A Solana approval uses the same request body. Its `signature` is the base58 string from [Solana signatures](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-wallet.md#solana-signatures).
>
> Sign and submit payloads from a single options response. Each response includes fresh signing data, so don't cache options.
>
> Revocation uses the same signing rules. Call `GET /v2/mandates/{mandateId}/revocations/wallet/options`, have the customer sign the returned payload (`eip2612` on EVM, `solana_subscription` on Solana, where the transaction is `revokeDelegation`), and submit the signature to `POST /v2/mandates/{mandateId}/revocations/wallet`.

### 3. Authorize a payment

For each payment, [create a payment session](https://docs.cdp.coinbase.com/payments/payment-acceptance/payment-sessions.md) for the amount due, then authorize it with the mandate. The customer doesn't need to take any action.

```bash theme={null}
cdp api -X POST /payment-sessions -e sandbox \
  amount=19.99 \
  asset=usdc \
  target.accountId=$ACCOUNT_ID \
  target.asset=usd \
  'autoCapture:=true' \
  externalReferenceId=invoice-2026-07

cdp api -X POST /payment-sessions/$SESSION_ID/authorizations/mandate -e sandbox \
  "Header:X-Idempotency-Key:8e03978e-40d5-43e8-bc93-6894a57f9324" \
  mandateId=$MANDATE_ID
```

> [accordion] Example response
>
> ```json theme={null}
> {
>   "authorizationId": "authorization_93d980d2-95f2-55fe-b9d3-2bd340cf10be",
>   "paymentSessionId": "paymentSession_82c879c1-84e1-44ed-a8c2-1ac239cf09ad",
>   "status": "pending",
>   "amount": "19.99",
>   "source": {
>     "address": "0xAbC1234567890aBcDeF1234567890AbCdEf123456",
>     "network": "base",
>     "asset": "usdc"
>   },
>   "mandateId": "mandate_82c879c1-84e1-44ed-a8c2-1ac239cf09ad",
>   "createdAt": "2026-07-01T09:30:00.000Z",
>   "updatedAt": "2026-07-01T09:30:00.000Z"
> }
> ```

The authorization returns in `pending` status and moves to `succeeded` or `failed`.

An authorization is rejected before any funds move if the mandate isn't usable or the amount breaks the spending limits. See [Mandate errors](#mandate-errors). If the customer's wallet doesn't hold enough to cover the payment, the authorization fails with `insufficient_funds`. See [Prevent failed mandate payments](#prevent-failed-mandate-payments) for how to catch that before billing day.

## After you authorize

From here, charges from both sources follow the [standard lifecycle](https://docs.cdp.coinbase.com/payments/payment-acceptance/overview.md#payment-lifecycle):

* **Auto-capture** suits most recurring charges, because you've already delivered the service or are about to.
* **Voids and refunds** return funds to the source: the customer's Coinbase account, or the wallet and network they approved from.
* **Idempotency keys** let your billing jobs retry safely. Always send `X-Idempotency-Key` so a retry never charges twice. See [Idempotency](https://docs.cdp.coinbase.com/payments/payment-acceptance/payment-sessions.md#idempotency).
* **Failed authorizations** can't be retried on the same session. To try again, create a new payment session and authorize it with a new `X-Idempotency-Key`.

## Mandate spending limits

Every mandate has a `policy` that caps what you can authorize. Coinbase checks the policy on every authorization.

| Limit | What it caps |
| - | - |
| `maxPerAuthorization` | The largest single authorization. |
| `maxPerPeriod` | The total you can authorize in a rolling `day`, `week`, `month`, or `year`. Windows are measured back from now, so `month` means the trailing 30 days, not the calendar month. |
| `minSetupBalance` | The minimum stablecoin balance the customer needs to approve the mandate. Customers always need at least \$1. Set a higher value to require more. It doesn't affect payment amounts. |

All limits are optional. Any limit you omit gets a Coinbase default, and limits above Coinbase's maximums are lowered to the maximum. The response always shows the limits that apply. An authorization over a limit is rejected with `422 mandate_policy_violation`, and no funds move.

We recommend setting your own limits that match your billing model. Your business is responsible for payments authorized with its mandates, so tight limits protect your customers from misuse. Leave some headroom above your expected payment amounts for plan upgrades, taxes, or usage spikes. Mandate terms can't be edited after you create the mandate. To change them, cancel the mandate, create a new one, and ask the customer to approve it.

## Ending a mandate

There are two ways to end a mandate, and they're independent.

| | Cancel | Revoke |
| - | - | - |
| **Who** | You | The customer |
| **How** | `POST /v2/mandates/{mandateId}/cancel` | The hosted `revocationUrl` or embedded component, or directly from their wallet |
| **Effect** | Blocks new authorizations immediately. Authorizations already in progress aren't affected. | Removes the onchain approval from the customer's wallet |
| **Timing** | Instant | Confirms onchain shortly after the customer signs |

When a customer ends their subscription, cancel the mandate so no further payments can be authorized, and point them to `revocationUrl` if they also want to remove the wallet approval. If a customer removes the approval directly from their wallet, your next authorization fails.

```bash theme={null}
cdp api -X POST /mandates/$MANDATE_ID/cancel -e sandbox \
  reason="Customer canceled their subscription."
```

## Prevent failed mandate payments

A payment fails with `insufficient_funds` when the customer's wallet doesn't hold enough of the mandate asset on the approved network. No funds move and the mandate stays usable, but the payment doesn't go through until you retry.

Unlike a card, a wallet's balance is public onchain, so you can spot a shortfall before you authorize. We recommend checking the balance in your billing job before every mandate payment and letting the customer know when they're short. Coinbase doesn't contact your customers.

### Check the balance before you authorize

1. Get `source.address`, `source.network`, and `source.asset` from the mandate, either from the `acceptance.mandate.approval_succeeded` webhook or `GET /v2/mandates/{mandateId}`. Payments are only pulled from this wallet, on this network, in this asset.

2. List the wallet's balances on `source.network` with the [Token Balances API](https://docs.cdp.coinbase.com/api-reference/v2/rest-api/evm-token-balances/list-evm-token-balances.md):

   ```bash theme={null}
   cdp api /evm/token-balances/$SOURCE_NETWORK/$SOURCE_ADDRESS
   ```

   > [accordion] Example response
   >
   > ```json theme={null}
   > {
   >   "balances": [
   >     {
   >       "amount": { "amount": "4100000", "decimals": 6 },
   >       "token": {
   >         "network": "base",
   >         "symbol": "USDC",
   >         "name": "USD Coin",
   >         "contractAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
   >       }
   >     }
   >   ]
   > }
   > ```

   > [!NOTE]
   >
   > The Token Balances API covers Base and Ethereum. For mandates approved from Arbitrum, Optimism, or Polygon, read the USDC balance of `source.address` with your own node or indexer, then continue from step 4.

3. Find the USDC entry by `token.contractAddress` (`0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` on Base, `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` on Ethereum), not by `symbol`. Results are paginated: if the response includes `nextPageToken`, pass it as `pageToken` to get the next page. If USDC isn't on any page, the balance is zero.

4. Compare the balance to the amount due. The API returns `amount` as an integer in USDC's smallest unit, where 1 USDC is 1,000,000 units (`decimals: 6`). Payment amounts like `19.99` are in whole USDC, so convert the amount due before you compare:

   | | Whole USDC | Smallest units |
   | - | - | - |
   | Wallet balance | 4.10 | `4100000` |
   | Amount due | 19.99 | `19990000` |

   `4100000` is less than `19990000`, so the customer is 15.89 USDC short.

5. If the balance is below the amount due, tell the customer how much to add and the address, network, and asset to send it to. Funds on another network or in another token don't count.

### If the payment fails anyway

A passing check doesn't guarantee the payment goes through, because the customer can move funds after you check. When an authorization fails, the `acceptance.payment_session.authorization_failed` webhook carries the reason in `data.authorization.error.code`.

If the reason is `insufficient_funds`, no funds moved and the mandate is still usable. To try again, create a new payment session and authorize it with the same `mandateId` and a new `X-Idempotency-Key`. Other failure reasons, such as a revoked mandate, need a different response. See [Mandate errors](#mandate-errors).

## Mandate statuses

`status` reflects the most recent action on the mandate.

| Status | Meaning |
| - | - |
| `created` | Waiting for the customer to approve. |
| `approval_pending` | Approval is confirming onchain. |
| `approval_succeeded` | Approved. You can authorize payments with it. |
| `approval_failed` | The last approval attempt failed. The customer can try again. |
| `canceled` | You canceled it. No further authorizations. |
| `revocation_pending` | The customer's revocation is confirming onchain. |
| `revocation_succeeded` | The customer removed their approval. No further authorizations. |
| `revocation_failed` | The revocation didn't complete. The approval is still in place. |

To decide whether you can authorize a payment with a mandate, check its timestamps rather than relying on `status` alone. A mandate is usable when `approvedAt` is set, `canceledAt` and `revokedAt` are empty, `expiresAt` is empty or in the future, and no revocation is in progress (`status` isn't `revocation_pending`). For example, a mandate in `revocation_failed` is still usable because the approval was never removed.

## Mandate errors

These errors mean Coinbase rejected a mandate authorization before any funds moved. Coinbase checks the mandate in this order and returns the first match.

| Error | HTTP status | What to do |
| - | - | - |
| `mandate_action_pending` | 409 | An approval or revocation is still confirming. Retry after it resolves. |
| `mandate_revoked` | 422 | The customer removed their approval. Ask them to approve a new mandate. |
| `mandate_canceled` | 422 | You canceled the mandate. Create a new one if the customer wants to continue. |
| `mandate_expired` | 400 | The mandate is past its `expiresAt`. Create a new one. |
| `mandate_invalid_status` | 422 | The mandate hasn't been approved yet. Wait for `approval_succeeded`. |
| `mandate_policy_violation` | 422 | The amount exceeds `maxPerAuthorization` or `maxPerPeriod`. Authorize a smaller amount, wait for room in the rolling window, or ask the customer to approve a new mandate with higher limits. |

Standard payment session errors, such as an expired authorization window, also apply. An `insufficient_funds` failure arrives asynchronously through the `acceptance.payment_session.authorization_failed` webhook rather than as an HTTP error. See [Prevent failed mandate payments](#prevent-failed-mandate-payments).

## Webhooks

Charges from both sources use the existing [payment session events](https://docs.cdp.coinbase.com/webhooks/payment-acceptance/overview.md#payment-session-events). For a Coinbase account, also subscribe to the [OAuth and identity events](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-coinbase.md#webhooks) to know when a link ends. For a wallet, subscribe to `acceptance.mandate.*` events to track approvals and cancellations:

| Event | Fires when |
| - | - |
| `acceptance.mandate.created` | You create a mandate |
| `acceptance.mandate.approval_initiated` | The customer signs and approval starts confirming |
| `acceptance.mandate.approval_succeeded` | The mandate is approved and ready to use |
| `acceptance.mandate.approval_failed` | Approval failed. The customer can retry |
| `acceptance.mandate.canceled` | You cancel the mandate |
| `acceptance.mandate.revocation_initiated` | The customer signs a revocation |
| `acceptance.mandate.revocation_succeeded` | The approval is removed from the customer's wallet |
| `acceptance.mandate.revocation_failed` | Revocation failed. The customer can retry |

Every mandate event's `data` contains the full `mandate`. Approval events also include `approval`, and revocation events include `revocation`, each with an `error` when the attempt fails. See [example payloads](https://docs.cdp.coinbase.com/webhooks/payment-acceptance/example-payloads.md) and [Webhooks](https://docs.cdp.coinbase.com/webhooks/payment-acceptance/overview.md) for setup.

## What to read next

> [card] [Payment Sessions](https://docs.cdp.coinbase.com/payments/payment-acceptance/payment-sessions.md)
>
> Create the session you authorize with a mandate

> [card] [Pay with Coinbase](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-coinbase.md)
>
> Link a Coinbase account and authorize payments with OAuth

> [card] [Pay with a Wallet](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-wallet.md)
>
> Connect a wallet and sign a one-time payment

> [card] [Webhooks](https://docs.cdp.coinbase.com/webhooks/payment-acceptance/overview.md)
>
> Subscribe to payment session, mandate, and OAuth events
