Skip to main content
Business & GrowthPayment Services252 lines

Coinbase Commerce

Accept cryptocurrency payments with Coinbase Commerce. Use this skill when the

Quick Summary34 lines
You are a payments specialist who integrates Coinbase Commerce into projects.
Coinbase Commerce lets merchants accept cryptocurrency payments (BTC, ETH, USDC,
and others) without holding crypto themselves — funds can auto-convert to fiat.

## Key Points

- Use small amounts on mainnet (e.g., $0.50 USDC) for end-to-end testing
- Use the charge API to create charges and inspect the response structure
- Use webhook test events from the Coinbase Commerce dashboard
- For local development, use a tunnel (ngrok) to receive webhooks
- Always verify webhook signatures with HMAC-SHA256 before processing
- Store `charge.id` and the `metadata` you set for reconciliation
- Only grant access on `charge:confirmed` — not `charge:pending`
- Handle `charge:delayed` gracefully — the customer did pay, just late
- Set `metadata` with your internal user/order IDs on every charge
- Display the charge `hosted_url` or addresses so customers can pay from any wallet
- Implement a status page that polls charge status for real-time UX updates
- Granting access on `charge:pending` — the transaction is not yet confirmed

## Quick Example

```typescript
const { data: charge } = await commerceRequest('GET', `/charges/${chargeId}`);

console.log(charge.timeline); // Array of status events
console.log(charge.payments); // Array of detected payments
// Status: NEW, PENDING, COMPLETED, EXPIRED, UNRESOLVED, RESOLVED, CANCELED
```

```typescript
await commerceRequest('POST', `/charges/${chargeId}/cancel`);
```
skilldb get payment-services-skills/Coinbase CommerceFull skill: 252 lines
Paste into your CLAUDE.md or agent config

Coinbase Commerce — Payment Integration

You are a payments specialist who integrates Coinbase Commerce into projects. Coinbase Commerce lets merchants accept cryptocurrency payments (BTC, ETH, USDC, and others) without holding crypto themselves — funds can auto-convert to fiat.

Core Philosophy

Crypto payments are push-based

Unlike card payments where you pull funds, crypto payments are push-based. You create a charge with an amount, the customer sends crypto to a generated address, and you wait for blockchain confirmation via webhooks.

Charges expire

Each charge generates a unique payment address and has a time window (typically 60 minutes). If the customer does not send funds in time, the charge expires.

Blockchain confirmations take time

Bitcoin transactions need multiple block confirmations (typically 2-6) before they are considered final. Webhooks notify you of each confirmation stage — pending, confirmed, and failed.

Setup

API key

Get your API key and webhook shared secret from the Coinbase Commerce dashboard at commerce.coinbase.com.

const COINBASE_COMMERCE_API = 'https://api.commerce.coinbase.com';

async function commerceRequest(method: string, path: string, body?: object) {
  const res = await fetch(`${COINBASE_COMMERCE_API}${path}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'X-CC-Api-Key': process.env.COINBASE_COMMERCE_API_KEY,
      'X-CC-Version': '2018-03-22',
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`Coinbase Commerce ${res.status}: ${await res.text()}`);
  return res.json();
}

Key Techniques

Create a charge

const { data: charge } = await commerceRequest('POST', '/charges', {
  name: 'Pro Plan',
  description: 'Monthly subscription — Pro Plan',
  pricing_type: 'fixed_price',
  local_price: {
    amount: '29.99',
    currency: 'USD',
  },
  metadata: {
    user_id: userId,
    order_id: orderId,
  },
  redirect_url: 'https://yourdomain.com/success',
  cancel_url: 'https://yourdomain.com/cancel',
});

// charge.hosted_url — redirect customer here
// charge.id — store for webhook matching
// charge.addresses — BTC, ETH, USDC addresses for direct payment

Create a checkout (reusable product link)

const { data: checkout } = await commerceRequest('POST', '/checkouts', {
  name: 'Pro Plan',
  description: 'Monthly Pro Plan subscription',
  pricing_type: 'fixed_price',
  local_price: {
    amount: '29.99',
    currency: 'USD',
  },
  requested_info: ['email', 'name'],
});

// checkout.hosted_url — shareable payment link

Retrieve a charge

const { data: charge } = await commerceRequest('GET', `/charges/${chargeId}`);

console.log(charge.timeline); // Array of status events
console.log(charge.payments); // Array of detected payments
// Status: NEW, PENDING, COMPLETED, EXPIRED, UNRESOLVED, RESOLVED, CANCELED

Cancel a charge

await commerceRequest('POST', `/charges/${chargeId}/cancel`);

List charges

const { data: charges, pagination } = await commerceRequest(
  'GET',
  '/charges?limit=25&order=desc'
);

Webhook Processing

EventAction
charge:createdCharge created, waiting for payment
charge:pendingPayment detected, awaiting confirmation
charge:confirmedPayment confirmed on blockchain — grant access
charge:failedPayment failed (e.g., underpayment, timeout)
charge:delayedPayment detected after charge expired
charge:resolvedPreviously unresolved charge manually resolved
import crypto from 'crypto';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('x-cc-webhook-signature');

  // Verify webhook signature
  const expected = crypto
    .createHmac('sha256', process.env.COINBASE_COMMERCE_WEBHOOK_SECRET)
    .update(body)
    .digest('hex');

  if (signature !== expected) {
    return new Response('Invalid signature', { status: 401 });
  }

  const event = JSON.parse(body);
  const charge = event.event.data;
  const userId = charge.metadata?.user_id;

  switch (event.event.type) {
    case 'charge:pending':
      // Payment sent, waiting for blockchain confirmation
      await markOrderPending(userId, charge.id);
      break;

    case 'charge:confirmed':
      // Payment confirmed — safe to grant access
      await grantAccess(userId, charge.id);
      break;

    case 'charge:failed':
      // Payment expired or underpaid
      await markOrderFailed(userId, charge.id);
      break;

    case 'charge:delayed':
      // Payment arrived after expiry — decide how to handle
      await handleDelayedPayment(userId, charge.id, charge.payments);
      break;
  }

  return new Response('OK', { status: 200 });
}

Handle underpayments and overpayments

function checkPaymentAmount(charge: any) {
  for (const payment of charge.payments) {
    const expected = parseFloat(payment.value.local.amount);
    const received = parseFloat(payment.value.crypto.amount);
    const status = payment.status; // CONFIRMED, PENDING

    if (payment.block?.confirmations_required > payment.block?.confirmations) {
      // Still awaiting confirmations
      continue;
    }

    // Coinbase reports underpayment/overpayment in the charge timeline
    // Check charge.timeline for UNDERPAID or OVERPAID context
  }

  const lastEvent = charge.timeline[charge.timeline.length - 1];
  if (lastEvent.context === 'UNDERPAID') {
    // Customer sent less than required — contact them or resolve manually
    return 'underpaid';
  }
  if (lastEvent.context === 'OVERPAID') {
    // Customer sent more — grant access, consider partial refund
    return 'overpaid';
  }
  return 'exact';
}

Embedding a payment button

<div>
  <a href="${charge.hosted_url}" target="_blank" rel="noopener">
    Pay with Crypto
  </a>
</div>

<!-- Or use Coinbase Commerce button -->
<script src="https://commerce.coinbase.com/v1/checkout.js"></script>
<div>
  <a class="buy-with-crypto" href="https://commerce.coinbase.com/checkout/${checkoutId}">
    Pay with Crypto
  </a>
</div>

Testing

Coinbase Commerce does not provide a sandbox. Testing approaches:

  • Use small amounts on mainnet (e.g., $0.50 USDC) for end-to-end testing
  • Use the charge API to create charges and inspect the response structure
  • Use webhook test events from the Coinbase Commerce dashboard
  • For local development, use a tunnel (ngrok) to receive webhooks

Best Practices

  • Always verify webhook signatures with HMAC-SHA256 before processing
  • Store charge.id and the metadata you set for reconciliation
  • Only grant access on charge:confirmed — not charge:pending
  • Handle charge:delayed gracefully — the customer did pay, just late
  • Set metadata with your internal user/order IDs on every charge
  • Display the charge hosted_url or addresses so customers can pay from any wallet
  • Implement a status page that polls charge status for real-time UX updates

Anti-Patterns

  • Granting access on charge:pending — the transaction is not yet confirmed
  • Ignoring charge:delayed events — the customer sent real money after expiry
  • Not handling underpayments — the customer may have sent partial funds
  • Relying on redirect URL alone without webhook verification for fulfillment
  • Creating charges without metadata — impossible to match to internal orders
  • Expecting instant confirmation — Bitcoin can take 10-60 minutes
  • Not informing users about the payment time window — charges expire
  • Building subscription logic on top of one-time charges without automation

Install this skill directly: skilldb add payment-services-skills

Get CLI access →