BLOG POST
Coinbase and EVM Payment Integration

I spent the last few months building payment infrastructure that stitches together Coinbase Commerce (the merchant-grade hosted checkout) with direct EVM wallet interactions via ethers.js v5. This post is everything I wish someone had written before I started. No fluff, no "blockchain will change the world" preamble. Just the technical reality of accepting crypto in a web app in 2022.
The stack context: I'm running Node.js/Express on the backend, React on the frontend, ethers.js v5.7.x for all chain interactions, Hardhat for contract work, and deploying across Vercel and Railway. If you're on a different stack, the concepts transfer - the pain points are universal.
The Two Integration Paths (And Why You Probably Need Both)
Let me save you the architectural deliberation I went through. There are two fundamentally different approaches to accepting crypto payments in a web app, and they serve different purposes:
Path A - Coinbase Commerce. This is the Stripe of crypto. You create a charge via their API, redirect the user to a hosted payment page (or embed addresses in your own UI), and listen for webhooks. No smart contracts. No wallet connections. The user scans a QR code, sends crypto, Coinbase detects it, you get a webhook. Done. If you just need to sell things for crypto and don't care about on-chain composability, this is the path.
Path B - Direct EVM integration. The user connects their wallet (MetaMask, Coinbase Wallet, whatever), you construct a transaction via ethers.js, they sign it in the browser, and you monitor the chain for confirmation. This gives you full control - custom smart contracts, token support beyond what Commerce offers, payment splitting, escrow logic, the works. The cost is complexity.
Most production systems I've seen - including what I'm building - end up using both. Commerce for the straightforward "pay $75 for this thing" flows, direct wallet integration for anything that needs on-chain logic. The backend unifies both through a single order management layer that processes Commerce webhooks and chain events identically.
Here's what the high-level architecture looks like:

Coinbase Commerce: The "Just Works" Path
Coinbase Commerce is a completely separate product from the Coinbase Exchange API. Different dashboard, different API keys, different base URL. I mention this because I've watched three developers in Discord waste hours trying to use exchange API credentials with Commerce endpoints. Don't be the fourth.
The API lives at https://api.commerce.coinbase.com. Authentication is a single API key passed via the X-CC-Api-Key header - you generate it from the Commerce dashboard under Settings → API Keys. Every request also needs X-CC-Version: 2018-03-22. Yes, that version string has been the same since launch. No, I don't know why they haven't updated it. It works, don't think about it too hard.
Here's what the SDK setup looks like with coinbase-commerce-node (v1.0.4 - it's essentially archived at this point, but it works):
const coinbase = require('coinbase-commerce-node');
const Client = coinbase.Client;
const Charge = coinbase.resources.Charge;
const Checkout = coinbase.resources.Checkout;
Client.init(process.env.COINBASE_COMMERCE_API_KEY);If you're avoiding the SDK (I don't blame you - it's thin enough that raw fetch calls are equally viable), the equivalent is:
const COMMERCE_BASE = 'https://api.commerce.coinbase.com';
async function commerceRequest(method, path, body = null) {
const res = await fetch(`${COMMERCE_BASE}${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(`Commerce API ${res.status}: ${await res.text()}`);
return res.json();
}
Supported cryptocurrencies as of October 2022: BTC, ETH, LTC, BCH, USDC, DAI, and DOGE. The ERC-20 tokens (USDC and DAI) run on Ethereum mainnet only - no Polygon USDC, no Arbitrum DAI. Additional tokens (USDT, APE, SHIB) are being added, but as I write this, those seven are what you can count on. Each charge generates one unique deposit address per enabled cryptocurrency.
Charges, Checkouts, and the Lifecycle You Need to Internalize
The core abstraction is the charge - an ephemeral payment object with unique blockchain addresses and locked exchange rates. Creating one:
const charge = await Charge.create({
name: 'Premium Hoodie',
description: 'Worldwide shipping included',
local_price: {
amount: '75.00',
currency: 'USD',
},
pricing_type: 'fixed_price', // or 'no_price' for donations
metadata: {
order_id: 'order_abc123',
customer_email: '[email protected]',
},
redirect_url: 'https://store.example.com/thank-you',
cancel_url: 'https://store.example.com/cart',
});
// charge.hosted_url → https://commerce.coinbase.com/charges/XXXXXXXX
// charge.addresses → { bitcoin: '3J98...', ethereum: '0xabc...', ... }
// charge.pricing → { bitcoin: { amount: '0.00352', currency: 'BTC' }, ... }The response gives you two options. You can redirect the user to charge.hosted_url where Coinbase handles the entire payment UI - crypto selection, QR codes, countdown timer, the works. Or you can pull charge.addresses and charge.pricing into your own custom checkout interface. I've done both. The hosted page is fine for most cases. Build custom only if your designer insists or you need to embed payment into a flow where a redirect would break the UX.
Checkouts are the reusable sibling of charges - think of them as templates. POST /checkouts creates a persistent payment link that can be shared, embedded, or reused across sessions. Same pricing and metadata options, but the object persists and doesn't expire after an hour.
Now, the part that matters: the charge lifecycle. Every charge has a timeline array that accumulates status events. Internalize this state machine or your webhook handler will be a mess:

The one that will bite you: UNRESOLVED with context DELAYED. This happens when a user sends the correct amount after the charge expires. The payment exists on-chain, real money moved, but the charge timed out. You need a process for this - either auto-resolve via the API or flag it for manual review. In my system, I auto-resolve DELAYED charges when the on-chain amount matches within 2%, and flag everything else for human eyes.
UNDERPAID is the other headache. User sends $74.50 instead of $75.00 because gas ate into their balance. Commerce won't mark it complete. You decide: eat the difference and resolve it, or ask the user for a top-up. Neither option is great. Build the handler upfront; don't discover this edge case in production.
Webhooks: Where 90% of Integrations Break
I'm going to be very direct about this: if your Coinbase Commerce webhook handler doesn't verify signatures against the raw request body, it is broken. Not "suboptimal." Broken. And I've reviewed code from three separate freelancers who got this wrong.
Coinbase Commerce sends webhook events as POST requests to URLs you configure under Settings → Webhook subscriptions. Six event types cover the full lifecycle:
| Event Type | When It Fires |
|---|---|
charge:created | Charge object created via API |
charge:pending | Transaction detected on-chain, awaiting confirmations |
charge:confirmed | Sufficient block confirmations received - payment complete |
charge:failed | Charge expired with no payment |
charge:delayed | Payment arrived after charge expiration |
charge:resolved | Merchant manually resolved an UNRESOLVED charge |
Each POST carries a X-CC-Webhook-Signature header - an HMAC-SHA256 hex digest of the raw request body, keyed with your webhook shared secret from the dashboard.
Here's where everyone breaks it. Express's express.json() middleware parses the body into a JavaScript object and discards the raw bytes. When you then JSON.stringify() the parsed object to verify the signature, you get a different byte sequence (key ordering, whitespace, unicode escaping can all differ). The signature check fails. The developer adds a // TODO: fix webhook verification comment. Six months later, the system is processing unverified webhooks in production. I've seen this happen.
The correct implementation:
const express = require('express');
const crypto = require('crypto');
const { Webhook } = require('coinbase-commerce-node');
const app = express();
// CRITICAL: Use express.raw() for the webhook route, NOT express.json()
// The signature is computed against the exact bytes Coinbase sent.
app.post(
'/api/webhooks/coinbase',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-cc-webhook-signature'];
if (!signature) {
return res.status(400).json({ error: 'Missing signature header' });
}
try {
// Using the SDK's built-in verification
const event = Webhook.verifyEventBody(
req.body, // This is a Buffer because of express.raw()
signature,
process.env.COINBASE_WEBHOOK_SECRET
);
// Idempotency: deduplicate by event.id
// If you've already processed this event, return 200 and move on.
// Coinbase retries with exponential backoff for up to 3 days.
const eventId = event.id;
console.log(`[webhook] ${event.type} | event=${eventId}`);
switch (event.type) {
case 'charge:confirmed':
handlePaymentConfirmed(event.data);
break;
case 'charge:pending':
handlePaymentPending(event.data);
break;
case 'charge:failed':
handlePaymentFailed(event.data);
break;
case 'charge:delayed':
handlePaymentDelayed(event.data);
break;
default:
console.log(`[webhook] Unhandled type: ${event.type}`);
}
// Always respond 200 immediately. Process async.
// If you respond with anything else, Coinbase retries.
res.status(200).send('OK');
} catch (error) {
console.error('[webhook] Signature verification failed:', error.message);
res.status(400).json({ error: 'Invalid signature' });
}
}
);If you're skipping the SDK and verifying manually:
function verifyWebhookSignature(rawBody, signature, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Use timingSafeEqual to prevent timing attacks.
// Yes, this matters. No, it's not paranoia.
return crypto.timingSafeEqual(
Buffer.from(signature, 'utf8'),
Buffer.from(computed, 'utf8')
);
}Important operational details:
Coinbase retries failed deliveries (anything that isn't HTTP 2xx) with exponential backoff for up to 3 days, maxing out at 1-hour intervals. This means your handler MUST be idempotent - store processed event IDs in your database and skip duplicates.
There is no sandbox environment. Let me repeat that because it's absurd: Coinbase Commerce has no test mode, no sandbox API, no fake charges. You test with real (small) payments. For local development, use ngrok or smee.io to tunnel webhooks to your machine:
# Terminal 1: Your Express server
node server.js # Listening on port 3000
# Terminal 2: ngrok tunnel
ngrok http 3000
# Gives you https://abc123.ngrok.io
# Set this as your webhook URL in Commerce dashboard:
# https://abc123.ngrok.io/api/webhooks/coinbaseCoinbase Wallet SDK and the WalletLink Protocol
The @coinbase/wallet-sdk package (I'm on v3.6.x as I write this) is the successor to the walletlink npm package, which is deprecated but still floating around in tutorials. If you see require('walletlink') in any guide, close that tab.
The SDK creates an EIP-1193 compatible Ethereum provider that bridges your browser dApp to the Coinbase Wallet mobile app through an encrypted relay protocol. The connection flow:

If the user has the Coinbase Wallet Chrome extension installed, the QR code step is skipped entirely - the extension injects its provider directly into window.ethereum.
Setting it up:
import CoinbaseWalletSDK from '@coinbase/wallet-sdk';
import { ethers } from 'ethers';
const coinbaseWallet = new CoinbaseWalletSDK({
appName: 'My Payment App',
appLogoUrl: 'https://myapp.com/logo.png',
darkMode: false,
});
// The first argument is a fallback JSON-RPC URL.
// Coinbase Wallet mobile uses its own RPC for supported networks,
// but falls back to this for unsupported ones or when needed.
const coinbaseProvider = coinbaseWallet.makeWeb3Provider(
`https://mainnet.infura.io/v3/${process.env.INFURA_KEY}`,
1 // chainId - 1 for Ethereum mainnet
);
// Wrap in ethers.js v5 Web3Provider for the API we all know
const provider = new ethers.providers.Web3Provider(coinbaseProvider);The SDK supports all EVM chains - Ethereum, Polygon, Arbitrum, Optimism, Avalanche, BNB Chain, Fantom, Gnosis Chain - with network switching via wallet_switchEthereumChain (EIP-3326) and wallet_addEthereumChain (EIP-3085).
The multi-wallet collision problem. If a user has both MetaMask and Coinbase Wallet extension installed, window.ethereum becomes a minefield. Coinbase Wallet creates a window.ethereum.providers array. You need to explicitly select:
function getProvider(walletType) {
if (!window.ethereum) {
throw new Error('No wallet detected');
}
// Multiple wallet extensions installed
if (window.ethereum.providers?.length) {
const found = window.ethereum.providers.find((p) => {
if (walletType === 'metamask') return p.isMetaMask;
if (walletType === 'coinbase') return p.isCoinbaseWallet;
return false;
});
if (!found) throw new Error(`${walletType} not found in providers`);
return new ethers.providers.Web3Provider(found);
}
// Single wallet extension
return new ethers.providers.Web3Provider(window.ethereum);
}Compared to WalletConnect (which covers 300+ wallets), the Coinbase SDK is ecosystem-specific. But Coinbase claims 73+ million verified users in 2022. If your target audience is Coinbase-native, the UX is significantly smoother than WalletConnect's relay-based approach.
React + ethers.js v5: Wallet-Driven Payment Flows
Let me be explicit about versions: this entire section uses ethers.js v5.7.x. The v6 rewrite is in progress but not shipped yet. If you're reading this in 2023 and v6 is out, the namespace structure changes significantly (ethers.utils.* flattens, ethers.providers.* moves) - adjust accordingly.
The standard wallet connection pattern in React. No wagmi, no rainbow-kit - just hooks and ethers, because I want you to understand what's actually happening before you abstract it away:
import { useState, useCallback, useEffect } from 'react';
import { ethers } from 'ethers';
function useWallet() {
const [account, setAccount] = useState(null);
const [provider, setProvider] = useState(null);
const [signer, setSigner] = useState(null);
const [chainId, setChainId] = useState(null);
const [error, setError] = useState(null);
const connect = useCallback(async () => {
try {
if (!window.ethereum) {
throw new Error('No Ethereum provider detected. Install MetaMask or Coinbase Wallet.');
}
const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
// This triggers the wallet popup
await web3Provider.send('eth_requestAccounts', []);
const web3Signer = web3Provider.getSigner();
const address = await web3Signer.getAddress();
const network = await web3Provider.getNetwork();
setProvider(web3Provider);
setSigner(web3Signer);
setAccount(address);
setChainId(network.chainId);
setError(null);
} catch (err) {
setError(err.message);
}
}, []);
// Listen for account and chain changes
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts) => {
if (accounts.length === 0) {
setAccount(null);
setSigner(null);
} else {
// Re-derive signer from new account
connect();
}
};
const handleChainChanged = (_chainId) => {
// ethers.js recommends full page reload on chain change
window.location.reload();
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
};
}, [connect]);
return { account, provider, signer, chainId, error, connect };
}
// Usage in a component
function PaymentPage() {
const { account, signer, chainId, error, connect } = useWallet();
return (
<div>
{!account ? (
<button onClick={connect}>Connect Wallet</button>
) : (
<div>
<p>Connected: {account}</p>
<p>Chain: {chainId}</p>
{/* Payment UI goes here */}
</div>
)}
{error && <p className="error">{error}</p>}
</div>
);
}Sending ETH and ERC-20 Tokens Programmatically
Sending ETH is straightforward - signer.sendTransaction() with ethers.utils.parseEther():
async function sendEthPayment(signer, recipientAddress, amountInEth) {
const tx = await signer.sendTransaction({
to: recipientAddress,
value: ethers.utils.parseEther(amountInEth),
// EIP-1559 gas params are auto-populated by ethers.js
// but we'll discuss manual estimation later
});
console.log(`Transaction hash: ${tx.hash}`);
// Wait for 1 confirmation
const receipt = await tx.wait(1);
console.log(`Confirmed in block ${receipt.blockNumber}`);
console.log(`Gas used: ${receipt.gasUsed.toString()}`);
return receipt;
}Sending ERC-20 tokens (USDC, DAI) requires a contract instance. The key addresses on Ethereum mainnet that you will memorize whether you want to or not:
| Token | Address | Decimals |
|---|---|---|
| USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | 6 |
| DAI | 0x6B175474E89094C44Da98b954EedeAC495271d0F | 18 |
| USDT | 0xdAC17F958D2ee523a2206206994597C13D831ec7 | 6 |
| WETH | 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 | 18 |
The minimal ERC-20 ABI - you don't need the full interface for payment flows:
const ERC20_ABI = [
'function transfer(address to, uint256 amount) returns (bool)',
'function transferFrom(address from, address to, uint256 amount) returns (bool)',
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
'function balanceOf(address owner) view returns (uint256)',
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
'event Transfer(address indexed from, address indexed to, uint256 amount)',
'event Approval(address indexed owner, address indexed spender, uint256 amount)',
];ethers.js v5 uses its human-readable ABI format - no JSON blobs needed. This is one of the library's genuinely elegant features.
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
async function sendUsdcPayment(signer, recipient, amountInUsdc) {
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, signer);
// USDC has 6 decimals, NOT 18. This mistake will cost someone money.
const amount = ethers.utils.parseUnits(amountInUsdc, 6);
// Check balance first - better UX than a reverted transaction
const balance = await usdc.balanceOf(await signer.getAddress());
if (balance.lt(amount)) {
throw new Error(
`Insufficient USDC. Have: ${ethers.utils.formatUnits(balance, 6)}, Need: ${amountInUsdc}`
);
}
const tx = await usdc.transfer(recipient, amount);
console.log(`USDC transfer tx: ${tx.hash}`);
const receipt = await tx.wait(1);
return receipt;
}The decimals footgun. USDC and USDT use 6 decimals. DAI and most other ERC-20s use 18. If you parseEther() a USDC amount, you're off by a factor of 10^12. I've seen this in production code. Always query decimals() if you're building a generic payment handler, or hardcode correctly if you're token-specific.
For smart contract interactions where the user pays into your contract (not a direct transfer), you'll need the approve + transferFrom pattern:
// Step 1: User approves your contract to spend their USDC
const approveTx = await usdc.approve(YOUR_CONTRACT_ADDRESS, amount);
await approveTx.wait(1);
// Step 2: Your contract calls transferFrom (triggered by a contract method)
const contract = new ethers.Contract(YOUR_CONTRACT_ADDRESS, YOUR_ABI, signer);
const payTx = await contract.payWithToken(USDC_ADDRESS, amount);
await payTx.wait(1);Smart Contract Payment Patterns with OpenZeppelin v4.x
Solidity 0.8.x (I'm on 0.8.17 as of this writing) gives us built-in overflow/underflow protection - SafeMath is dead, stop importing it. Custom errors (0.8.4+) save gas over string revert messages. OpenZeppelin Contracts v4.x is the standard library. Use it. Don't roll your own access control or reentrancy guards unless you have a very specific reason and an auditor who agrees with that reason.
OpenZeppelin's PaymentSplitter - perfect for revenue sharing:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/finance/PaymentSplitter.sol";
// Inherits everything. Payees call release() to withdraw their share.
// Supports both ETH and ERC-20 via release(IERC20, account).
// Shares are immutable after deployment - set them right.
contract TeamRevenue is PaymentSplitter {
constructor(
address[] memory payees,
uint256[] memory shares_
) PaymentSplitter(payees, shares_) {}
}
// Deploy with:
// payees: [founderAddr, devAddr, treasuryAddr]
// shares: [50, 30, 20]
// → founder gets 50%, dev gets 30%, treasury gets 20%
PaymentSplitter uses a pull model - funds accumulate in the contract, and each payee calls release() to withdraw. This is intentional. Push-based payment (sending ETH to multiple addresses in one transaction) is vulnerable to griefing if any recipient is a contract that reverts on receive().
Custom payment receiver - for when you need more control:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Address.sol";
contract PaymentReceiver is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
using Address for address payable;
// Events are your backend's best friend.
// Monitor these via ethers.js WebSocket filters.
event EthPaymentReceived(
address indexed from,
uint256 amount,
bytes32 indexed orderId
);
event TokenPaymentReceived(
address indexed token,
address indexed from,
uint256 amount,
bytes32 indexed orderId
);
event FundsWithdrawn(
address indexed to,
uint256 amount
);
// Accept ETH with an order reference
function payWithEth(bytes32 orderId) external payable {
require(msg.value > 0, "Zero payment");
emit EthPaymentReceived(msg.sender, msg.value, orderId);
}
// Accept ERC-20 with an order reference
// User must approve() this contract first
function payWithToken(
IERC20 token,
uint256 amount,
bytes32 orderId
) external {
require(amount > 0, "Zero payment");
token.safeTransferFrom(msg.sender, address(this), amount);
emit TokenPaymentReceived(address(token), msg.sender, amount, orderId);
}
// Owner withdrawal - ETH
function withdraw(address payable to, uint256 amount)
external
onlyOwner
nonReentrant
{
require(address(this).balance >= amount, "Insufficient ETH");
to.sendValue(amount); // OZ Address.sendValue, not .transfer()
emit FundsWithdrawn(to, amount);
}
// Owner withdrawal - ERC-20
function withdrawToken(IERC20 token, address to, uint256 amount)
external
onlyOwner
{
token.safeTransfer(to, amount);
}
// Allow plain ETH transfers (no order reference)
receive() external payable {
emit EthPaymentReceived(msg.sender, msg.value, bytes32(0));
}
}Why Address.sendValue() instead of transfer() or send()? After EIP-1884 increased gas costs for SLOAD, the 2300 gas stipend that transfer() forwards became insufficient for contracts with non-trivial receive() functions. sendValue() forwards all available gas. This is the recommended pattern as of 2022 and has been for a while. If you're still using .transfer(), stop.
Why SafeERC20? Some tokens (USDT being the notorious one) don't return a boolean from transfer() and approve(), violating the ERC-20 spec. SafeERC20 handles these non-compliant tokens. Use it for every ERC-20 interaction.
Hardhat Setup, Deployment, and the Foundry Question
Hardhat is the standard. I'll address Foundry at the end of this section for those who want the bleeding edge, but if you're shipping a product nowadays, Hardhat is where the ecosystem support, plugin library, and documentation live.
mkdir payment-contracts && cd payment-contracts
npm init -y
npm install --save-dev hardhat @nomiclabs/hardhat-ethers \
@nomiclabs/hardhat-waffle @nomiclabs/hardhat-etherscan \
ethereum-waffle chai ethers
npm install @openzeppelin/contracts
npx hardhat init
# Select "Create a JavaScript project"hardhat.config.js:
require('@nomiclabs/hardhat-ethers');
require('@nomiclabs/hardhat-waffle');
require('@nomiclabs/hardhat-etherscan');
require('dotenv').config();
module.exports = {
solidity: {
version: '0.8.17',
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
networks: {
goerli: {
url: `https://eth-goerli.g.alchemy.com/v2/${process.env.ALCHEMY_GOERLI_KEY}`,
accounts: [process.env.DEPLOYER_PRIVATE_KEY],
},
mainnet: {
url: `https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_MAINNET_KEY}`,
accounts: [process.env.DEPLOYER_PRIVATE_KEY],
},
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
},
};Deployment script (scripts/deploy.js):
const hre = require('hardhat');
async function main() {
const [deployer] = await hre.ethers.getSigners();
console.log('Deploying with:', deployer.address);
console.log('Balance:', hre.ethers.utils.formatEther(
await deployer.getBalance()
));
const PaymentReceiver = await hre.ethers.getContractFactory('PaymentReceiver');
const receiver = await PaymentReceiver.deploy();
await receiver.deployed();
console.log('PaymentReceiver deployed to:', receiver.address);
// Wait for 5 confirmations before verifying
console.log('Waiting for confirmations...');
await receiver.deployTransaction.wait(5);
// Verify on Etherscan
console.log('Verifying on Etherscan...');
await hre.run('verify:verify', {
address: receiver.address,
constructorArguments: [],
});
console.log('Verified.');
}
main().catch((error) => {
console.error(error);
process.exit(1);
});# Deploy to Goerli testnet first. Always.
npx hardhat run scripts/deploy.js --network goerli
# When you're confident:
npx hardhat run scripts/deploy.js --network mainnet
On Foundry. Paradigm released Foundry v0.2.0 in March 2022, and it's genuinely impressive - 5–340x faster compilation and test execution, tests written in Solidity instead of JavaScript, native fuzzing, gas snapshots. Uniswap v4 and Compound v3 are building with it. But here's the reality in October 2022: the documentation is still evolving, the deployment scripting (forge script) is less mature than Hardhat's plugin ecosystem, Windows requires WSL, and if you need to ask a question in Discord, you'll get answers - but slowly. If you're an experienced Solidity developer who's comfortable with the bleeding edge, Foundry is worth adopting. If you're building a product and need to ship, Hardhat with the battle-tested plugin ecosystem is the pragmatic choice. I'm running both - Hardhat for deployment and integration, Foundry for its testing speed. They coexist fine in the same repo.
EIP-1559 Gas Estimation (The Part Everyone Gets Wrong)
EIP-1559 has been live since August 2021. By now, every transaction should use the Type 2 format with maxFeePerGas and maxPriorityFeePerGas. If your code is still setting gasPrice, it works (legacy transactions are still valid), but you're overpaying.
The three gas parameters under EIP-1559:

The recommended formula for maxFeePerGas:
maxFeePerGas = (baseFee * 2) + maxPriorityFeePerGasThe * 2 multiplier covers up to six consecutive 100%-full blocks (baseFee increases ~12.5% per full block). In practice, this means your transaction remains viable even during sudden demand spikes.
ethers.js v5's provider.getFeeData() gives you estimates, but it hardcodes maxPriorityFeePerGas at 1.5 gwei. During low-activity periods, 1.5 gwei is fine. During NFT mints or market crashes, it's not enough. For better estimates:
async function getGasEstimate(provider) {
const feeData = await provider.getFeeData();
// ethers default - works most of the time
console.log('ethers defaults:', {
maxFeePerGas: ethers.utils.formatUnits(feeData.maxFeePerGas, 'gwei'),
maxPriorityFeePerGas: ethers.utils.formatUnits(feeData.maxPriorityFeePerGas, 'gwei'),
});
// Better: query the node directly for current priority fee
const priorityFeeHex = await provider.send('eth_maxPriorityFeePerGas', []);
const priorityFee = ethers.BigNumber.from(priorityFeeHex);
// And get the latest baseFee from the pending block
const latestBlock = await provider.getBlock('latest');
const baseFee = latestBlock.baseFeePerGas;
// Apply the 2x buffer
const maxFee = baseFee.mul(2).add(priorityFee);
return {
maxFeePerGas: maxFee,
maxPriorityFeePerGas: priorityFee,
};
}
// Use in a transaction:
const gasParams = await getGasEstimate(provider);
const tx = await signer.sendTransaction({
to: recipient,
value: ethers.utils.parseEther('0.1'),
...gasParams,
});For production systems handling significant volume, consider the eth_feeHistory RPC method or the @mycrypto/gas-estimation package, which computes percentile-based estimates from recent block history. Blocknative's gas API is another option if you want a third-party oracle.
On-Chain Payment Monitoring via WebSockets
If you're doing direct wallet payments (Path B), you need a backend process that watches the chain for incoming transactions. Do not poll with HTTP. Use WebSocket connections.
Alchemy and Infura both offer WebSocket endpoints. Alchemy's free tier is more generous for this use case - 100 WebSocket connections, 1,000 subscriptions per connection, and enhanced subscription types like alchemy_pendingTransactions with address filtering.
const { ethers } = require('ethers');
// WebSocket provider - persistent connection, real-time events
const wsProvider = new ethers.providers.WebSocketProvider(
`wss://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`
);
const PAYMENT_CONTRACT = '0xYourContractAddress';
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
// ──────────────────────────────────────────────────
// Monitor: ERC-20 payments TO our contract
// ──────────────────────────────────────────────────
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
const usdcFilter = usdc.filters.Transfer(null, PAYMENT_CONTRACT);
usdc.on(usdcFilter, (from, to, amount, event) => {
console.log(`[USDC] ${ethers.utils.formatUnits(amount, 6)} from ${from}`);
console.log(` tx: ${event.transactionHash}`);
console.log(` block: ${event.blockNumber}`);
// → match to order, update database, notify user
});
// ──────────────────────────────────────────────────
// Monitor: ETH payments TO our contract
// (No Transfer event for native ETH - scan blocks)
// ──────────────────────────────────────────────────
wsProvider.on('block', async (blockNumber) => {
try {
const block = await wsProvider.getBlockWithTransactions(blockNumber);
for (const tx of block.transactions) {
if (
tx.to?.toLowerCase() === PAYMENT_CONTRACT.toLowerCase() &&
tx.value.gt(0)
) {
console.log(`[ETH] ${ethers.utils.formatEther(tx.value)} from ${tx.from}`);
console.log(` tx: ${tx.hash}`);
// → match to order, update database, notify user
}
}
} catch (err) {
console.error(`Block scan error at ${blockNumber}:`, err.message);
}
});
// ──────────────────────────────────────────────────
// Monitor: Custom contract events (PaymentReceived)
// ──────────────────────────────────────────────────
const paymentContract = new ethers.Contract(
PAYMENT_CONTRACT,
[
'event EthPaymentReceived(address indexed from, uint256 amount, bytes32 indexed orderId)',
'event TokenPaymentReceived(address indexed token, address indexed from, uint256 amount, bytes32 indexed orderId)',
],
wsProvider
);
paymentContract.on('EthPaymentReceived', (from, amount, orderId, event) => {
console.log(`[Contract ETH] Order ${orderId}: ${ethers.utils.formatEther(amount)}`);
});
paymentContract.on('TokenPaymentReceived', (token, from, amount, orderId, event) => {
console.log(`[Contract Token] Order ${orderId}: token=${token}, amount=${amount}`);
});
// ──────────────────────────────────────────────────
// Handle WebSocket disconnections
// ──────────────────────────────────────────────────
wsProvider._websocket.on('close', () => {
console.error('WebSocket disconnected. Reconnecting...');
// Implement reconnection logic with exponential backoff
// In production, use a process manager (PM2) or container restart policy
});
Critical note on confirmation depth. A single block confirmation is vulnerable to reorgs. For low-value payments (< $100), 1–3 confirmations are reasonable. For significant amounts, wait for 12+ confirmations on Ethereum mainnet. Polygon and other L2s/sidechains have different finality characteristics - Polygon recommends 256 blocks (~8.5 minutes) for full finality.
Deployment Architecture: Heroku Is Dying, Now What?
Heroku announced the end of its free tier on August 25, 2022, effective November 28, 2022. This is relevant because half the crypto payment tutorials on the internet deploy to Heroku free dynos.
Here's my recommended deployment architecture for a crypto payment application in late 2022:

Why split frontend and backend? The backend needs a persistent process for WebSocket chain monitoring - serverless functions can't hold open WebSocket connections. The frontend is static assets that belong on a CDN.
Vercel for the frontend - free tier gives you unlimited deployments, 100 GB bandwidth, automatic HTTPS, global edge CDN, and zero-config React/Next.js deployment. Push to main, it deploys. Done.
Railway for the backend - this is where the Heroku refugees are going, and for good reason. Railway offers $5/month in free credits (enough for a small backend), git push deployment, built-in Postgres provisioning, usage-based pricing, and - critically - apps run continuously. No sleeping after 30 minutes like Heroku free tier used to do. The deployment model is identical: connect your GitHub repo, set environment variables, deploy.
# Railway deployment is absurdly simple
npm install -g @railway/cli
railway login
railway init
railway up
# That's it. Set env vars in the Railway dashboard.If you're using Next.js and want a single deployment, Vercel is the play. API routes (pages/api/webhook.js) handle webhooks, and the React frontend lives alongside. The tradeoff is serverless function constraints - cold starts, execution timeouts, and no persistent WebSocket connections. For the webhook handler specifically, disable Vercel's body parser:
// pages/api/webhooks/coinbase.js
export const config = {
api: {
bodyParser: false, // We need the raw body for signature verification
},
};
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).end();
}
// Read raw body manually
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const rawBody = Buffer.concat(chunks);
// Verify signature against rawBody
// ... (same verification logic as before)
}AWS Lambda + API Gateway is the enterprise serverless option. Free tier gives you 1 million requests/month. The gotcha for webhook verification: API Gateway can mangle the request body if you're not careful about the passthrough settings. Use Lambda proxy integration to get the raw body.
DigitalOcean App Platform starting at $5/month for dynamic apps. Similar to Railway but with DO's broader infrastructure ecosystem. Good if you're already in the DigitalOcean world.
Security: Every Layer, No Exceptions
I'm going to structure this by layer because security isn't a section you add - it's a property of the entire system.
Layer 1: Private key management. Your private keys never touch the frontend. They never go in version control. They never go in environment variables prefixed with NEXT_PUBLIC_ (Next.js bundles those into client JavaScript - I've seen API keys in production bundles). Backend .env files with dotenv for development. Platform-specific secrets management for production:
| Platform | Secret Storage |
|---|---|
| Vercel | Environment Variables (Settings → Environment Variables) |
| Railway | Variables tab (per service, per environment) |
| Heroku | Config Vars |
| AWS | Secrets Manager (~$0.40/secret/month) or SSM Parameter Store |
| Self-hosted | HashiCorp Vault, SOPS, or sealed-secrets for K8s |
For HD wallet derivation (BIP-32/39/44), a single master seed stored securely generates unlimited child addresses per user or per transaction. Never reuse addresses across users if you're building a custodial-adjacent flow.
Layer 2: Webhook security. Already covered in detail above. The short version: HMAC-SHA256 verification against raw bytes, crypto.timingSafeEqual() to prevent timing attacks, idempotent processing with event ID deduplication, HTTPS-only endpoints.
Layer 3: Smart contract security. Four patterns that are non-negotiable in 2022:
ReentrancyGuard. Apply nonReentrant to every function that makes external calls or transfers funds. The cost is ~2,500–5,000 gas per call. The alternative is losing your entire contract balance to a reentrancy attack, which in 2016 cost The DAO $60 million. Not a tradeoff worth optimizing.
Checks-Effects-Interactions. Structural defense independent of the modifier: validate all conditions first, update contract state second, make external calls last. Combining this with ReentrancyGuard is belt-and-suspenders, and that's exactly the appropriate level of caution for code that holds money.
Front-running protection. On Ethereum mainnet, pending transactions are visible in the public mempool. Sandwich bots will exploit this for swaps and certain payment patterns. Flashbots Protect (rpc.flashbots.net) routes transactions through a private mempool invisible to MEV searchers. It's free and widely adopted. For your users, you can suggest adding the Flashbots Protect RPC to their wallet. For your backend transactions, use it as your default RPC endpoint.
Access control. OpenZeppelin's Ownable for single-owner patterns, AccessControl for role-based permissions. Never use tx.origin for authorization - it enables phishing attacks where a malicious contract can impersonate the caller. Always use msg.sender.
// BAD - vulnerable to phishing
require(tx.origin == owner, "Not owner");
// GOOD - use Ownable or check msg.sender directly
require(msg.sender == owner, "Not owner");
// Or just: onlyOwner modifier from OwnableLayer 4: Frontend and API security.
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const helmet = require('helmet');
const app = express();
// Rate limiting - protect webhook and API endpoints
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: 'Too many requests',
}));
// CORS - restrict to your frontend domain, not '*'
app.use(cors({
origin: process.env.FRONTEND_URL, // e.g., 'https://myapp.com'
methods: ['GET', 'POST'],
}));
// Helmet - security headers
app.use(helmet());
Never trust client-side data for payment confirmation. The frontend can tell the user "payment sent!" but the backend must independently verify via webhook or on-chain event before fulfilling any order. The frontend is untrusted territory.
Architecture Diagrams
Here's the complete payment flow for both integration paths:
Path A: Coinbase Commerce Flow

Path B: Coinbase Commerce Flow

Environment Variable Reference
For quick setup, here's every environment variable your system needs:
# ──────────────────────────────────────────────
# .env (backend)
# ──────────────────────────────────────────────
# Coinbase Commerce
COINBASE_COMMERCE_API_KEY=your_commerce_api_key
COINBASE_WEBHOOK_SECRET=your_webhook_shared_secret
# Ethereum RPC (Alchemy recommended)
ALCHEMY_MAINNET_KEY=your_alchemy_mainnet_key
ALCHEMY_GOERLI_KEY=your_alchemy_goerli_key
# Contract deployment
DEPLOYER_PRIVATE_KEY=0xYourDeployerPrivateKey
ETHERSCAN_API_KEY=your_etherscan_api_key
# Your deployed contract
PAYMENT_CONTRACT_ADDRESS=0xYourDeployedContract
# Application
FRONTEND_URL=https://your-frontend.vercel.app
PORT=3000
NODE_ENV=production
# ──────────────────────────────────────────────
# .env.local (frontend - Next.js)
# ──────────────────────────────────────────────
# These are SAFE for frontend (prefixed NEXT_PUBLIC_)
NEXT_PUBLIC_PAYMENT_CONTRACT=0xYourDeployedContract
NEXT_PUBLIC_USDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
NEXT_PUBLIC_CHAIN_ID=1
NEXT_PUBLIC_API_URL=https://your-backend.railway.app
# These are NOT prefixed - backend-only in Next.js API routes
COINBASE_COMMERCE_API_KEY=your_commerce_api_key
COINBASE_WEBHOOK_SECRET=your_webhook_shared_secretConclusion
Two integration paths, both production-viable, best combined:
Coinbase Commerce gets you accepting crypto payments in an afternoon. Create charges, redirect to hosted pages, verify webhooks, fulfill orders. No smart contracts, no wallet connections. The limitation is flexibility - you're constrained to Commerce's supported tokens, Ethereum mainnet for ERC-20s, and their checkout UX.
Direct EVM integration via ethers.js v5 and custom smart contracts gives you complete control - any token, any chain, any payment logic. The cost is real engineering work: wallet connection handling, gas estimation, on-chain monitoring, smart contract development and auditing, and a more complex deployment architecture.
The toolchain nowadays is mature enough to build production-grade payment systems. Hardhat for contracts, OpenZeppelin v4.x for battle-tested primitives, ethers.js v5 for chain interactions, Vercel plus Railway for deployment. The ecosystem moves fast - ethers v6 is coming, Foundry is gaining ground, new L2s are shipping monthly - but the patterns in this guide are stable foundations that will serve you well even as the specifics evolve.
If you hit issues, the Coinbase Commerce Discord and the ethers.js GitHub discussions are the most responsive support channels. The Hardhat Discord is also solid. And if you find a bug in any of my code samples - which is possible, I wrote this at 3 AM after a deployment - reach out and I'll fix it.
Ship something.
If you're facing similar challenges, let's talk.
Bring the current architecture context and delivery constraints, and we can map out a focused next step.
Book a Discovery CallNewsletter
Stay connected
Not ready for a call? Get the next post directly.