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

# Pay with Coinbase

> Let Coinbase customers pay with their USDC balance by linking their Coinbase account through OAuth.

Pay with Coinbase lets Coinbase customers pay with the USDC in their Coinbase account. The customer links their Coinbase account once through OAuth. You then use their stored token to authorize each payment.

## Prerequisites

Pay with Coinbase requires Payment Acceptance onboarding, where you receive an OAuth client (`client_id`, `client_secret`) and register your `redirect_uri`. [Contact our team](mailto:coinbase.payments@coinbase.com) to get started.

Examples on this page use production hosts. In Sandbox, use these hosts instead:

| API | Production | Sandbox |
| - | - | - |
| Account linking and authorization challenges | `login.coinbase.com` | `sandbox.cdp.coinbase.com` |
| Payment Acceptance API | `api.cdp.coinbase.com` | `sandbox.cdp.coinbase.com` |
| Balance check | `api.coinbase.com` | None. Production only. |

## How it works

```mermaid theme={null}
sequenceDiagram
    actor C as Customer
    participant M as Your backend
    participant O as Coinbase OAuth
    participant A as Acceptance API

    Note over C,A: Once per customer
    C->>M: Select "Link Coinbase account"
    M->>O: POST /oauth2/init-oauth-session
    O-->>M: link_url
    M->>C: Open link_url
    C->>O: Sign in and consent
    O-->>C: Redirect to redirect_uri
    M->>O: POST /oauth2/confirm-link
    O-->>M: access_token + refresh_token

    Note over C,A: Every payment
    M->>A: POST /v2/payment-sessions (API key)
    M->>O: POST /api/v1/authorization-challenges (OAuth token)
    opt 2FA required
        M->>C: Redirect to challengeUrl
        C->>O: Complete 2FA
    end
    M->>A: POST .../authorizations/coinbase (OAuth token + cb-authz-id)
    A-)M: Webhook: authorization_succeeded
    M->>A: POST .../captures (API key)
```

## 1. Link the customer's Coinbase account

Linking uses OAuth 2.0 with PKCE and returns the access and refresh tokens you need for every payment.

> [!WARNING]
>
> Keep `client_secret` and the PKCE `code_verifier` on your backend. Never send them to a browser or mobile app.

### Required scopes

Request all nine scopes:

| Scope | Purpose |
| - | - |
| `wallet:user:read` | View basic Coinbase user information |
| `wallet:payment-methods:read` | View linked payment methods available for top-ups |
| `coinbase:stablecoins:topup-read` | View stablecoin top-up requests and their status |
| `coinbase:stablecoins:topup-create` | Create stablecoin top-up requests |
| `coinbase:stablecoins:trade-read` | View stablecoin trades and their status |
| `coinbase:stablecoins:trade-create` | Convert supported balances into stablecoins such as USDC |
| `coinbase:stablecoins:payment-read` | View stablecoin payments and their status |
| `coinbase:stablecoins:payment-create` | Create and authorize stablecoin payments |
| `coinbase:stablecoins:balance-read` | View stablecoin balances available for payments |

A scope your client isn't allowed to request returns `invalid_scope`. A missing required scope makes authorization fail with `403 forbidden`.

### Start a linking session

`POST https://login.coinbase.com/oauth2/init-oauth-session`

**Authentication:** CDP API key, `Authorization: Bearer <JWT>` ([generate a JWT](https://docs.cdp.coinbase.com/api-reference/v2/authentication.md))

| Parameter | Required | Description |
| - | - | - |
| `external_reference` | Yes | Your unique ID for this linking attempt. Echoed in all responses. |
| `redirect_uri` | Yes | Your registered callback URL. Coinbase redirects the customer here after consent. |
| `client_id` | Yes | Your OAuth client ID. |
| `client_secret` | Yes | Your OAuth client secret, sent in the request body. |
| `scopes` | Yes | Space-separated [required scopes](#required-scopes). |
| `state` | Yes | Random string of at least 32 characters, to protect against CSRF. |
| `code_challenge` | Yes | PKCE code challenge derived from your `code_verifier`. |
| `code_challenge_method` | Yes | Must be `S256`. |

```bash theme={null}
curl -X POST https://login.coinbase.com/oauth2/init-oauth-session \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "external_reference": "link_ref_123",
    "redirect_uri": "https://merchant.example.com/oauth/callback",
    "client_id": "YOUR_OAUTH_CLIENT_ID",
    "client_secret": "YOUR_OAUTH_CLIENT_SECRET",
    "scopes": "wallet:user:read wallet:payment-methods:read coinbase:stablecoins:topup-read coinbase:stablecoins:topup-create coinbase:stablecoins:trade-read coinbase:stablecoins:trade-create coinbase:stablecoins:payment-read coinbase:stablecoins:payment-create coinbase:stablecoins:balance-read",
    "state": "a8f3k2p9q1r7t4w6x0y5z3b2c8d1e9f2",
    "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
    "code_challenge_method": "S256"
  }'
```

```json theme={null}
{
  "external_reference": "link_ref_123",
  "oauth_challenge": "ch_abc456",
  "link_url": "https://www.coinbase.com/oauth/connect?oauth_challenge=ch_abc456"
}
```

### Send the customer to Coinbase

Open `link_url`. It opens the Coinbase app when installed (Universal Link on iOS, App Link on Android), and an in-app browser otherwise. After the customer signs in and consents, Coinbase redirects them to your `redirect_uri`.

### Confirm the link

After the redirect, exchange the session for tokens:

`POST https://login.coinbase.com/oauth2/confirm-link`

**Authentication:** CDP API key, `Authorization: Bearer <JWT>`

```bash theme={null}
curl -X POST https://login.coinbase.com/oauth2/confirm-link \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "external_reference": "link_ref_123",
    "oauth_challenge": "ch_abc456",
    "client_id": "YOUR_OAUTH_CLIENT_ID",
    "client_secret": "YOUR_OAUTH_CLIENT_SECRET",
    "code_verifier": "YOUR_CODE_VERIFIER"
  }'
```

```json theme={null}
{
  "access_token": "6915ab99857fec1e6f2f6c078583756d...",
  "refresh_token": "73a3431906de603504c1e8437709b0f4...",
  "user_id": "9da7a204-544e-5fd1-9a12-61176c5d4cd8",
  "expiry_ttl": 3600,
  "expires_at": 1750000000
}
```

| Field | Description |
| - | - |
| `access_token` | Bearer token for calls on the customer's behalf. Expires after 1 hour. |
| `refresh_token` | Exchanges for new tokens when the access token expires. |
| `user_id` | Stable Coinbase account ID. Same across linking attempts. |
| `expiry_ttl` | Seconds until the access token expires. |
| `expires_at` | Unix timestamp (seconds) when the access token expires. |

Store both tokens, `user_id`, and your `external_reference`, all mapped to your customer. `oauth.sign_in.completed` and `oauth.token.revoked` identify the customer by `externalReferenceId`, which holds the `external_reference` you sent. Identity events use `coinbaseUserId`.

### Refresh tokens

An expired access token returns `401 Unauthorized`. Exchange the refresh token for new tokens:

`POST https://login.coinbase.com/oauth2/token`

**Authentication:** OAuth client credentials, `client_id` and `client_secret` in the form body

```bash theme={null}
curl -X POST https://login.coinbase.com/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode "refresh_token=$REFRESH_TOKEN"
```

```json theme={null}
{
  "access_token": "new_access_token...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "new_refresh_token...",
  "scope": "wallet:user:read wallet:payment-methods:read ..."
}
```

Each refresh invalidates the previous `refresh_token`, so store the new one before you use the new access token. Sending an invalidated refresh token ends the link. Run one refresh at a time per customer. `invalid_grant` means the link was revoked or expired. The customer must link again.

### Unlink an account

To disconnect the account, for example from a **Disconnect Coinbase** button, revoke either token:

`POST https://login.coinbase.com/oauth2/revoke`

**Authentication:** OAuth client credentials, `client_id` and `client_secret` in the form body

```bash theme={null}
curl -X POST https://login.coinbase.com/oauth2/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode "token=$ACCESS_OR_REFRESH_TOKEN"
```

Revoking one token revokes both. Delete the stored tokens after a `200` response. Later calls return `401` until the customer links again.

## 2. Check the customer's USDC balance

Check that the customer can cover the payment before you create it. This call needs the `coinbase:stablecoins:balance-read` scope.

`GET https://api.coinbase.com/v2/accounts/USDC`

**Authentication:** Customer's OAuth token, `Authorization: Bearer <access_token>`

```bash theme={null}
curl "https://api.coinbase.com/v2/accounts/USDC?include_available_to_send=true" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json theme={null}
{
  "data": {
    "currency": { "code": "USDC", "exponent": 6 },
    "balance": { "amount": "250.000000", "currency": "USDC" },
    "available_to_send": { "amount": "200.000000", "currency": "USDC" }
  }
}
```

Use `available_to_send`, not `balance`. `balance` includes funds on hold.

| `available_to_send.amount` | Action |
| - | - |
| Greater than or equal to the payment amount | Continue |
| Lower than the payment amount | Stop and show an insufficient funds message |
| Omitted | Treat the balance as unknown. Continue, and handle `coinbase_insufficient_balance` asynchronously. |

Amounts are decimal strings with six decimal places. Compare them as decimals, not as strings or floats.

> [!NOTE]
>
> The balance check is production only. In Sandbox, skip it and rely on the asynchronous `coinbase_insufficient_balance` error.

## 3. Create a payment session

`POST https://api.cdp.coinbase.com/platform/v2/payment-sessions`

**Authentication:** CDP API key, `Authorization: Bearer <JWT>`

```bash theme={null}
curl -X POST https://api.cdp.coinbase.com/platform/v2/payment-sessions \
  -H "Authorization: Bearer $JWT" \
  -H "X-Idempotency-Key: 3f2a6c1e-8b4d-4e2a-9c7f-1a2b3c4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "10.00",
    "asset": "usdc",
    "target": { "accountId": "'$ACCOUNT_ID'", "asset": "usd" },
    "autoCapture": false
  }'
```

See [Payment Sessions](https://docs.cdp.coinbase.com/payments/payment-acceptance/payment-sessions.md) for all session fields.

## 4. Authorize the payment

Start a challenge, complete 2FA if Coinbase requires it, then submit the authorization. Every call uses the customer's access token.

### Start an authorization challenge

Call this before every authorization. Coinbase decides whether 2FA is required.

`POST https://login.coinbase.com/api/v1/authorization-challenges`

**Authentication:** Customer's OAuth token, `Authorization: Bearer <access_token>`

```bash theme={null}
curl -X POST https://login.coinbase.com/api/v1/authorization-challenges \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "operationParameters": {
      "amount": "10.00",
      "asset": "usdc",
      "target": { "accountId": "'$ACCOUNT_ID'", "asset": "usd" },
      "externalReferenceId": "order-12345",
      "initiator": "customer"
    },
    "authorizationParameters": { "enforce2fa": true },
    "callbackUrl": "https://merchant.example.com/callback"
  }'
```

| Field | Required | Description |
| - | - | - |
| `operationParameters.amount` | Yes | Must match the payment session amount. |
| `operationParameters.asset` | Yes | Payment asset, for example `usdc`. |
| `operationParameters.target` | Yes | Must match the session target (`accountId`, `asset`). |
| `operationParameters.externalReferenceId` | No | Your reference ID for this payment. `oauth.mfa.*` webhooks identify the payment only by this ID, so set it if you use them. |
| `operationParameters.initiator` | No | `customer` (default) or `merchant`. See [Charge without the customer present](#charge-without-the-customer-present). |
| `authorizationParameters.enforce2fa` | Yes | `true` always requires 2FA. `false` lets Coinbase risk policy decide. |
| `callbackUrl` | Yes | HTTPS URL returned when the challenge completes. |

```json theme={null}
{
  "authorizationStatus": "AUTHORIZATION_STATUS_CHALLENGE_PENDING",
  "authorizationId": "a1b2c3d4-e5f6-4890-abcd-ef1234567890",
  "challengeUrl": "https://login.coinbase.com/api/v1/authorization-session?token=a1b2c3d4-e5f6-4890-abcd-ef1234567890",
  "expiryTtl": 300
}
```

| `authorizationStatus` | Next step |
| - | - |
| `AUTHORIZATION_STATUS_AUTHORIZED` | [Submit the authorization](#submit-the-authorization). No 2FA required. |
| `AUTHORIZATION_STATUS_CHALLENGE_PENDING` | [Complete 2FA](#complete-2fa-if-required). |
| `AUTHORIZATION_STATUS_UNAUTHORIZED_LIMIT_EXCEEDED` | Stop. The customer exceeded their daily payment limit. |
| `AUTHORIZATION_STATUS_UNAUTHORIZED_BLOCKED` | Stop. Coinbase blocked the payment. |
| `AUTHORIZATION_STATUS_EXPIRED` | Start a new challenge. 2FA wasn't completed in time. |
| `AUTHORIZATION_STATUS_CANCELLED` | Start a new challenge. |

### Complete 2FA if required

1. Redirect the customer to `challengeUrl` to complete 2FA on a Coinbase page.
2. Poll `GET https://login.coinbase.com/api/v1/authorization-challenges?authorizationId={authorizationId}` with the customer's access token until `authorizationStatus` is `AUTHORIZATION_STATUS_AUTHORIZED`, or wait for the `oauth.mfa.completed` webhook.

The challenge expires after `expiryTtl` seconds.

### Submit the authorization

This places a hold on the customer's USDC balance.

`POST https://api.cdp.coinbase.com/platform/v2/payment-sessions/{paymentSessionId}/authorizations/coinbase`

**Authentication:** Customer's OAuth token, `Authorization: Bearer <access_token>`, not your CDP API key

| Header | Required | Description |
| - | - | - |
| `cb-authz-id` | Yes | `authorizationId` from the challenge, a lowercase UUID v4 |
| `X-Idempotency-Key` | Recommended | Prevents a retry from authorizing twice. See [Idempotency](https://docs.cdp.coinbase.com/payments/payment-acceptance/payment-sessions.md#idempotency). |

```bash theme={null}
curl -X POST \
  https://api.cdp.coinbase.com/platform/v2/payment-sessions/$SESSION_ID/authorizations/coinbase \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "cb-authz-id: $AUTHORIZATION_CHALLENGE_ID" \
  -H "X-Idempotency-Key: 7d9e1b3a-2c4f-4a6b-8d0e-5f6a7b8c9d0e" \
  -H "Content-Type: application/json" \
  -d '{}'
```

A body is required. Send `{}` if you have no `metadata`, `externalReferenceId`, or `customerDisplay.referenceCode`.

```json theme={null}
{
  "authorizationId": "authorization_82c879c1-84e1-44ed-a8c2-1ac239cf09ad",
  "paymentSessionId": "paymentSession_82c879c1-84e1-44ed-a8c2-1ac239cf09ad",
  "status": "pending",
  "amount": "10.00",
  "source": {
    "coinbaseUserId": "9da7a204-544e-5fd1-9a12-61176c5d4cd8"
  },
  "createdAt": "2025-06-15T12:01:00.000Z",
  "updatedAt": "2025-06-15T12:01:00.000Z"
}
```

The authorization starts as `pending` and moves to `succeeded` or `failed`. Track it with the `acceptance.payment_session.authorization_succeeded` and `acceptance.payment_session.authorization_failed` webhooks, or poll `GET /v2/payment-sessions/{id}/authorizations/{authorizationId}`.

The `authorizationId` in this response (`authorization_…`) identifies the payment authorization. It's different from the challenge `authorizationId` you sent in `cb-authz-id`. Use this one to poll.

> [!NOTE]
>
> This endpoint doesn't check the balance. A customer without enough USDC still gets `200` with `status: "pending"`, then the authorization fails with `coinbase_insufficient_balance`. Run the [balance check](#2-check-the-customers-usdc-balance) first.

## 5. Capture, void, or refund

These calls use your CDP API key, as for any session. With `autoCapture: true`, funds are captured after authorization succeeds. Otherwise, capture after fulfillment or void to release the hold. Refunds return funds to the customer's Coinbase account. See [Payment Sessions](https://docs.cdp.coinbase.com/payments/payment-acceptance/payment-sessions.md).

## Charge without the customer present

For recurring or usage-based charges while the customer is away, start the challenge with `initiator: "merchant"` and `enforce2fa: false`:

```json theme={null}
{
  "operationParameters": {
    "amount": "9.99",
    "asset": "usdc",
    "target": { "accountId": "account_6cf3295d-f5af-508c-8667-ac7a547a0cdd", "asset": "usd" },
    "initiator": "merchant"
  },
  "authorizationParameters": { "enforce2fa": false },
  "callbackUrl": "https://merchant.example.com/callback"
}
```

| Scenario | Customer present? | `initiator` | `enforce2fa` |
| - | - | - | - |
| One-time payment started by the customer | Yes | `customer` | Per your risk policy |
| First charge of a recurring series | Yes | `merchant` | Per your risk policy |
| Later charge of a recurring series | No | `merchant` | `false` |

> [!WARNING]
>
> Never set `enforce2fa: true` when the customer is away. Nobody can complete the 2FA redirect, so the challenge stays in `AUTHORIZATION_STATUS_CHALLENGE_PENDING`.

For the full recurring setup, including token refresh in billing jobs, see [Recurring Payments](https://docs.cdp.coinbase.com/payments/payment-acceptance/recurring-payments.md#charge-a-coinbase-account).

## Errors

### Account linking

Errors return an `error` field. The message is in `error_message` (`init-oauth-session`, `confirm-link`) or `error_description` (`token`, `revoke`).

| `error` | Cause |
| - | - |
| `invalid_request` | Malformed body, unregistered `redirect_uri`, missing or invalid PKCE parameters, `state` missing or shorter than 32 characters, invalid `external_reference`, or missing `oauth_challenge`, `client_id`, or `code_verifier` |
| `unauthorized` | The CDP API key is invalid, expired, or doesn't match the `client_id` |
| `invalid_scope` | The client is not allowed to request these scopes |
| `invalid_grant` | The session expired or was not found, the refresh token is invalid or revoked, the `code_verifier` doesn't match, the customer hasn't finished consent, or the session was already redeemed |
| `internal_server_error` | Unexpected failure. Retry with exponential backoff. |

### Authorization challenge

| `errorType` | Cause |
| - | - |
| `invalid_request` | A required field is missing or malformed, or `callbackUrl` is not HTTPS |
| `invalid_operation_parameters` | The operation parameters were rejected |
| `unauthenticated` | The OAuth token is missing or invalid |
| `challenge_expired` | The challenge expired before the customer completed 2FA |
| `challenge_not_found` | No challenge exists with this `authorizationId` |
| `invalid_configuration` | Your OAuth client is not configured for authorization challenges |
| `too_many_requests` | Rate limit exceeded |
| `internal_server_error` | Unexpected failure |

### Coinbase authorization

Errors specific to `POST .../authorizations/coinbase`. See the [API Reference](https://docs.cdp.coinbase.com/api-reference/v2/rest-api/payment-sessions/authorize-a-payment-session-with-a-coinbase-account.md) for the full list.

| HTTP | `errorType` | Cause |
| - | - | - |
| `400` | `invalid_request` | The customer couldn't be identified from the token, or a Coinbase account source isn't compatible with the session's target |
| `400` | `authorization_expired` | The session's authorization window closed. Create a new session. |
| `401` | `unauthorized` | The OAuth token is missing, invalid, expired, or revoked |
| `403` | `forbidden` | The credential is not an OAuth token, a required scope is missing, or your OAuth client doesn't own the session |
| `409` | `payment_session_already_authorized` | The session already has a succeeded authorization |
| `409` | `payment_session_action_pending` | Another authorization is in progress |
| `429` | `daily_transaction_limit_exceeded` | The customer reached their daily transaction count limit |
| `429` | `daily_amount_limit_exceeded` | The customer reached their daily amount limit |
| `503` | `service_unavailable` | Coinbase authorization is temporarily disabled |

### Asynchronous authorization failures

The `acceptance.payment_session.authorization_failed` webhook gives the reason in `data.authorization.error.code`:

| `code` | Cause |
| - | - |
| `coinbase_insufficient_balance` | The customer's USDC balance was too low to place the hold |
| `coinbase_account_restricted` | A risk or compliance review rejected the payment. The customer must sign in to Coinbase for details. |
| `authorization_expired` | The authorization window closed before processing started |
| `timed_out` | The authorization didn't finish in time |
| `internal_error` | Unexpected failure after all internal retries |
| `invalid_parameters` | The authorization was configured with invalid parameters |

These failures are final. To retry, create a new payment session. Match on `code`, not `message`. For `timed_out`, `internal_error`, and `invalid_parameters`, contact support with the `correlationId` and session ID.

## Webhooks

Besides [payment session events](https://docs.cdp.coinbase.com/webhooks/payment-acceptance/overview.md), subscribe to OAuth and identity events to track each linked account:

| Event type | Description |
| - | - |
| `oauth.sign_in.completed` | The customer signed in or signed up and consented. Call `confirm-link` next. `data.intent`, when present, says which. |
| `oauth.mfa.completed` | The customer completed a 2FA challenge |
| `oauth.mfa.canceled` | The customer abandoned a 2FA challenge |
| `oauth.token.revoked` | The customer disconnected your app from their Coinbase account |
| `identity.user.updated` | The customer changed their Coinbase profile data |
| `identity.user.deleted` | The customer's Coinbase account was closed |

* `oauth.token.revoked` fires when the customer disconnects your app in Coinbase, not when you call `/oauth2/revoke`. Don't rely on it alone: also treat `invalid_grant` on refresh as a revoked link. A `401` on authorization can mean the access token expired, so refresh once and retry before you treat the link as revoked.
* On `identity.user.deleted`, stop processing payments for that customer.

See [example payloads](https://docs.cdp.coinbase.com/webhooks/payment-acceptance/example-payloads.md#oauth-and-identity-events).

## What to read next

> **[Payment Sessions](https://docs.cdp.coinbase.com/payments/payment-acceptance/payment-sessions.md)**
>
> Expiries, balances, auto-capture, and target types

> **[Pay with a Wallet](https://docs.cdp.coinbase.com/payments/payment-acceptance/pay-with-wallet.md)**
>
> Let customers pay from a self-custody wallet

> **[Recurring Payments](https://docs.cdp.coinbase.com/payments/payment-acceptance/recurring-payments.md)**
>
> Charge a linked account on your schedule

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

> **[API Reference - Coinbase Auth](https://docs.cdp.coinbase.com/api-reference/v2/rest-api/payment-sessions/authorize-a-payment-session-with-a-coinbase-account.md)**
>
> Coinbase authorization API
