Coinbase Commerce Integration
Coinbase Commerce — non-custodial payment processing: funds go directly to your wallet, bypassing Coinbase. Supports BTC, ETH, USDC, DAI, LTC, BCH and other assets. Key difference from custodial processors: Coinbase doesn't hold your money, no KYC for receiver, no risk of funds freezing from processor.
What We Integrate
Two main API objects:
- Charge — one-time payment request with fixed amount, tied to specific order
- Checkout — reusable payment page (suitable for donations where amount is arbitrary)
For e-commerce we use Charges.
Creating Charge via API
const axios = require("axios");
async function createCharge(orderId, amountUSD, description) {
const response = await axios.post(
"https://api.commerce.coinbase.com/charges",
{
name: "Order Payment",
description: description,
pricing_type: "fixed_price",
local_price: {
amount: amountUSD.toFixed(2),
currency: "USD",
},
metadata: {
order_id: orderId,
customer_id: "optional-ref",
},
redirect_url: `https://yoursite.com/orders/${orderId}/success`,
cancel_url: `https://yoursite.com/orders/${orderId}/cancel`,
},
{
headers: {
"X-CC-Api-Key": process.env.COINBASE_COMMERCE_API_KEY,
"X-CC-Version": "2018-03-22",
},
}
);
return response.data.data; // contains hosted_url, code, addresses
}
hosted_url — ready Coinbase Commerce page, redirect user there. Coinbase shows addresses in different networks for user choice, QR, countdown timer (15 minutes by default for crypto rate).
Webhook Handling
Most important part. Coinbase Commerce sends events on every status change:
const crypto = require("crypto");
app.post("/webhooks/coinbase", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-cc-webhook-signature"];
const webhookSecret = process.env.COINBASE_COMMERCE_WEBHOOK_SECRET;
// Verify signature — HMAC-SHA256 of raw body
const expectedSig = crypto
.createHmac("sha256", webhookSecret)
.update(req.body)
.digest("hex");
if (signature !== expectedSig) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body);
switch (event.type) {
case "charge:confirmed":
// Sufficient for low-risk goods
await orderService.markConfirmed(event.data.metadata.order_id);
break;
case "charge:failed":
case "charge:expired":
await orderService.markFailed(event.data.metadata.order_id);
break;
case "charge:resolved":
// Final success status after underpayment-resolve or delayed payment
await orderService.markResolved(event.data.metadata.order_id);
break;
}
res.json({ received: true });
});
Important: req.body should be raw Buffer for signature verification — don't parse via express.json() before verification, otherwise signature won't match.
Charge State Machine
NEW → PENDING (first transaction received) → CONFIRMED → RESOLVED
→ EXPIRED (timer expired)
→ FAILED (underpayment, timeout)
→ UNRESOLVED (requires manual review)
CONFIRMED happens after sufficient confirmations (depends on network: Bitcoin — 3, Ethereum — 12). For most goods CONFIRMED is enough. RESOLVED — final status, means complete processing including overpayment refunds.
Polling as Fallback
Webhook may not arrive — set up periodic sync. Coinbase Commerce API lets you get Charge status by code:
// Run every 5 minutes for pending charges
async function syncPendingCharges() {
const pending = await db.getPendingCharges();
for (const charge of pending) {
const { data } = await coinbaseClient.get(`/charges/${charge.code}`);
const timeline = data.data.timeline;
const latestStatus = timeline[timeline.length - 1].status;
if (["CONFIRMED", "RESOLVED"].includes(latestStatus)) {
await orderService.markPaid(charge.orderId);
}
}
}
What to Implement
- Charge creation endpoint and redirect to
hosted_url - Webhook handler with HMAC verification
- Save
charge.codein database for reconciliation - Fallback polling for pending charges
- UI waiting page with polling status (GET
/charges/:codeevery 10 sec)







