Crypto payments are no longer experimental. Stablecoin transaction volume hit $33 trillion in 2025 — a 72% increase over the prior year — and USDC alone accounted for $18.3 trillion of that volume. On the merchant side, 46% of surveyed merchants have already integrated cryptocurrency payment options, driven by faster settlement, lower fees, and cross-border reach. The crypto payment gateway market is projected to grow from $2 billion in 2025 to $2.39 billion in 2026, a 19% year-over-year increase.
The core problem: customers want to pay with whatever token they hold. Merchants want to receive stablecoins. A crypto payment gateway bridges that gap by accepting any token and auto-converting it to USDC or USDT at settlement.
This guide shows you how to build a non-custodial crypto payment gateway in TypeScript that accepts any ERC-20 token on any supported chain and auto-converts it to stablecoins using swapapi.dev — a free DEX aggregator API covering 46 EVM chains with no API key required.
What You'll Need
- Node.js 18+ or Bun runtime
- ethers.js v6 for wallet and transaction management
- Express or Hono for the payment API server
- A merchant wallet address to receive settlement funds
- Native gas tokens on your target chain for swap execution
- No API keys — swapapi.dev is free and keyless
Install the dependencies:
bun add ethers hono
Step 1: Define the Payment Configuration
Create a file called payment-gateway.ts. Start by defining the settlement tokens, supported chains, and merchant wallet address.
interface ChainConfig {
chainId: number;
settlementToken: string;
settlementDecimals: number;
nativeToken: string;
rpcUrl: string;
}
const CHAINS: Record<number, ChainConfig> = {
1: {
chainId: 1,
settlementToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
settlementDecimals: 6,
nativeToken: "ETH",
rpcUrl: "https://cloudflare-eth.com",
},
42161: {
chainId: 42161,
settlementToken: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
settlementDecimals: 6,
nativeToken: "ETH",
rpcUrl: "https://arb1.arbitrum.io/rpc",
},
8453: {
chainId: 8453,
settlementToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
settlementDecimals: 6,
nativeToken: "ETH",
rpcUrl: "https://mainnet.base.org",
},
56: {
chainId: 56,
settlementToken: "0x55d398326f99059fF775485246999027B3197955",
settlementDecimals: 18,
nativeToken: "BNB",
rpcUrl: "https://bsc-dataseed.binance.org",
},
};
const MERCHANT_WALLET = "0xYourMerchantWalletAddress";
const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
Each chain maps to a settlement stablecoin. On Ethereum, Arbitrum, and Base that is USDC (6 decimals). On BSC, USDT uses 18 decimals — a common gotcha that causes miscalculated amounts if you assume 6 decimals everywhere. Always use the decimals value from the chain config.
Step 2: Create the Payment Quote Function
The payment flow starts when a customer selects what token they want to pay with. The gateway fetches a swap quote to show them the exact conversion rate before they commit.
According to a PayPal survey, 88% of U.S. merchants have received customer inquiries about paying with crypto. This function handles the conversion quote for any incoming token:
interface PaymentQuote {
orderId: string;
chainId: number;
tokenIn: string;
tokenOut: string;
amountIn: string;
expectedSettlement: string;
minSettlement: string;
priceImpact: number;
tx: {
from: string;
to: string;
data: string;
value: string;
};
expiresAt: number;
}
async function getPaymentQuote(
chainId: number,
tokenIn: string,
amountIn: string,
sender: string,
orderId: string
): Promise<PaymentQuote> {
const chain = CHAINS[chainId];
if (!chain) {
throw new Error(`Chain ${chainId} not supported`);
}
const url = new URL(`https://api.swapapi.dev/v1/swap/${chainId}`);
url.searchParams.set("tokenIn", tokenIn);
url.searchParams.set("tokenOut", chain.settlementToken);
url.searchParams.set("amount", amountIn);
url.searchParams.set("sender", sender);
url.searchParams.set("maxSlippage", "0.005");
const response = await fetch(url.toString(), {
signal: AbortSignal.timeout(15000),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error?.message ?? "Quote failed");
}
if (result.data.status === "NoRoute") {
throw new Error("No swap route available for this token pair");
}
if (result.data.priceImpact < -0.05) {
throw new Error("Price impact too high — try a smaller amount");
}
return {
orderId,
chainId,
tokenIn: result.data.tokenFrom.address,
tokenOut: result.data.tokenTo.address,
amountIn: result.data.amountIn,
expectedSettlement: result.data.expectedAmountOut,
minSettlement: result.data.minAmountOut,
priceImpact: result.data.priceImpact,
tx: {
from: result.data.tx.from,
to: result.data.tx.to,
data: result.data.tx.data,
value: result.data.tx.value,
},
expiresAt: Date.now() + 30000,
};
}
The function calls swapapi.dev's single GET endpoint to get executable swap calldata. The 30-second expiry is critical — DEX calldata includes a deadline, and prices shift constantly. Always re-fetch if the quote is stale.
Step 3: Format Settlement Amounts for Display
Customers need to see human-readable settlement amounts before confirming. Raw amounts from the API are in the token's smallest unit — for USDC with 6 decimals, 2500000000 means 2,500.00 USDC.
function formatSettlement(
rawAmount: string,
decimals: number
): string {
const value = BigInt(rawAmount);
const divisor = BigInt(10 ** decimals);
const whole = value / divisor;
const fraction = value % divisor;
const paddedFraction = fraction.toString().padStart(decimals, "0");
const trimmed = paddedFraction.slice(0, 2);
return `${whole}.${trimmed}`;
}
function isQuoteExpired(quote: PaymentQuote): boolean {
return Date.now() > quote.expiresAt;
}
Step 4: Build the Payment API Server
The payment server exposes two endpoints: one to request a quote and one to confirm payment. This is where customers interact with your crypto payment gateway.
Stablecoins account for 76% of all crypto payments at the merchant level, which is why auto-settling to stablecoins makes your gateway compatible with existing accounting workflows.
import { Hono } from "hono";
import { ethers } from "ethers";
const app = new Hono();
const quotes = new Map<string, PaymentQuote>();
app.post("/api/payment/quote", async (c) => {
const body = await c.req.json();
const { chainId, tokenIn, amount, sender, orderId } = body;
try {
const quote = await getPaymentQuote(
chainId,
tokenIn,
amount,
sender,
orderId
);
const chain = CHAINS[chainId];
quotes.set(orderId, quote);
return c.json({
success: true,
data: {
orderId: quote.orderId,
settlementAmount: formatSettlement(
quote.expectedSettlement,
chain.settlementDecimals
),
minSettlementAmount: formatSettlement(
quote.minSettlement,
chain.settlementDecimals
),
priceImpact: quote.priceImpact,
tx: quote.tx,
expiresIn: 30,
},
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown error";
return c.json({ success: false, error: message }, 400);
}
});
The server stores quotes in memory keyed by order ID. In production, use Redis or a database with TTL expiry. The tx object returned to the frontend contains everything the customer's wallet needs to execute the swap.
Step 5: Handle Payment Verification
After the customer submits the transaction, your backend needs to verify that the swap executed and the settlement token arrived.
interface PaymentResult {
orderId: string;
status: "confirmed" | "failed" | "pending";
settlementAmount: string;
txHash: string;
}
async function verifyPayment(
orderId: string,
txHash: string
): Promise<PaymentResult> {
const quote = quotes.get(orderId);
if (!quote) {
throw new Error("Quote not found");
}
const chain = CHAINS[quote.chainId];
const provider = new ethers.JsonRpcProvider(chain.rpcUrl);
const receipt = await provider.waitForTransaction(
txHash,
1,
60000
);
if (!receipt || receipt.status === 0) {
return {
orderId,
status: "failed",
settlementAmount: "0",
txHash,
};
}
const erc20Interface = new ethers.Interface([
"event Transfer(address indexed from, address indexed to, uint256 value)",
]);
let settlementReceived = BigInt(0);
for (const log of receipt.logs) {
try {
const parsed = erc20Interface.parseLog({
topics: log.topics as string[],
data: log.data,
});
if (
parsed &&
parsed.name === "Transfer" &&
log.address.toLowerCase() ===
chain.settlementToken.toLowerCase()
) {
settlementReceived += parsed.args.value;
}
} catch {
continue;
}
}
const formatted = formatSettlement(
settlementReceived.toString(),
chain.settlementDecimals
);
return {
orderId,
status: "confirmed",
settlementAmount: formatted,
txHash,
};
}
app.post("/api/payment/verify", async (c) => {
const { orderId, txHash } = await c.req.json();
try {
const result = await verifyPayment(orderId, txHash);
quotes.delete(orderId);
return c.json({ success: true, data: result });
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown error";
return c.json({ success: false, error: message }, 500);
}
});
The verification function parses transaction logs to confirm that the settlement token (USDC/USDT) was received. It checks for ERC-20 Transfer events matching the settlement token contract address, then sums the amounts.
Step 6: Add ERC-20 Approval Handling
When the customer pays with an ERC-20 token (not the native gas token), they must first approve the DEX router to spend their tokens. This is a standard EVM requirement, not specific to any swap provider.
Bloomberg Intelligence projects stablecoin payment flows could reach $56 trillion by 2030. Handling approvals smoothly is table stakes for any crypto payment gateway that wants to capture this growth.
async function checkAndBuildApproval(
chainId: number,
tokenIn: string,
amountIn: string,
sender: string,
spender: string
): Promise<{ needsApproval: boolean; approvalTx?: object }> {
if (tokenIn.toLowerCase() === NATIVE_TOKEN.toLowerCase()) {
return { needsApproval: false };
}
const chain = CHAINS[chainId];
const provider = new ethers.JsonRpcProvider(chain.rpcUrl);
const erc20 = new ethers.Contract(
tokenIn,
[
"function allowance(address owner, address spender) view returns (uint256)",
],
provider
);
const currentAllowance = await erc20.allowance(sender, spender);
if (currentAllowance >= BigInt(amountIn)) {
return { needsApproval: false };
}
const iface = new ethers.Interface([
"function approve(address spender, uint256 amount)",
]);
const data = iface.encodeFunctionData("approve", [
spender,
ethers.MaxUint256,
]);
return {
needsApproval: true,
approvalTx: {
to: tokenIn,
data,
value: "0",
},
};
}
One edge case to handle: USDT on Ethereum requires resetting the allowance to zero before setting a new value. If you support Ethereum USDT payments, add a zero-approval step first.
Step 7: Build the Frontend Payment Flow
The frontend collects the customer's token choice, fetches a quote, handles approvals, and submits the swap transaction.
async function processPayment(
orderId: string,
chainId: number,
tokenIn: string,
amount: string
) {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const sender = await signer.getAddress();
const quoteRes = await fetch("/api/payment/quote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chainId,
tokenIn,
amount,
sender,
orderId,
}),
});
const quote = await quoteRes.json();
if (!quote.success) {
throw new Error(quote.error);
}
const approval = await checkAndBuildApproval(
chainId,
tokenIn,
amount,
sender,
quote.data.tx.to
);
if (approval.needsApproval && approval.approvalTx) {
const approvalTx = await signer.sendTransaction(
approval.approvalTx
);
await approvalTx.wait();
const freshQuoteRes = await fetch("/api/payment/quote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chainId,
tokenIn,
amount,
sender,
orderId,
}),
});
const freshQuote = await freshQuoteRes.json();
quote.data = freshQuote.data;
}
const gasEstimate = await provider.estimateGas({
from: quote.data.tx.from,
to: quote.data.tx.to,
data: quote.data.tx.data,
value: BigInt(quote.data.tx.value),
});
const txResponse = await signer.sendTransaction({
to: quote.data.tx.to,
data: quote.data.tx.data,
value: BigInt(quote.data.tx.value),
gasLimit: (gasEstimate * BigInt(120)) / BigInt(100),
});
const verifyRes = await fetch("/api/payment/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orderId,
txHash: txResponse.hash,
}),
});
return verifyRes.json();
}
After approval confirms, the code fetches a fresh quote (since calldata expires in 30 seconds), estimates gas with a 20% buffer, and submits the swap. The verification call then confirms settlement.
Comparison: Crypto Payment Gateway Approaches
| Approach | Custody | Fees | Chains | Setup | Settlement |
|---|---|---|---|---|---|
| Hosted gateway (Coinbase Commerce, BitPay) | Custodial | 1-2% per tx | 3-5 | Minutes | USD/crypto |
| Self-hosted with CEX API | Semi-custodial | Exchange fees | Limited | Hours | Exchange balance |
| DEX aggregator API (swapapi.dev) | Non-custodial | Gas only | 46 chains | Hours | Direct to wallet |
| Custom smart contract | Non-custodial | Gas + audit cost | 1 per deploy | Weeks | On-chain |
The DEX aggregator approach gives you the broadest chain support and true non-custodial settlement. Funds go directly to the merchant wallet — no intermediary holds funds at any point.
Handling Edge Cases
Production crypto payment gateways need to handle several edge cases:
Partial fills: swapapi.dev may return a "Partial" status when liquidity is limited. The amountIn and expectedAmountOut fields reflect the partial amount, not your original request. Check data.status and decide whether to accept the partial payment or prompt the customer to try a different token.
Rate limiting: The API allows approximately 30 requests per minute per IP. For high-traffic gateways, cache quotes and implement request queuing. If you receive a RATE_LIMITED error, wait 5-10 seconds before retrying with exponential backoff.
Gas estimation failures: If eth_estimateGas fails, the swap would likely revert on-chain. Do not submit the transaction. Instead, return an error to the customer suggesting a different amount or token.
Stale quotes: Always check the 30-second expiry. If the customer takes too long to confirm, re-fetch the quote before submitting.
FAQ
What is a crypto payment gateway and how does it work?
A crypto payment gateway is a system that allows merchants to accept cryptocurrency payments. It works by receiving a customer's token, converting it to the merchant's preferred settlement currency (typically a stablecoin like USDC), and forwarding the proceeds to the merchant's wallet. The gateway in this guide uses a DEX aggregator API to handle the token-to-stablecoin conversion in a single atomic transaction.
How much does it cost to run a crypto payment gateway?
With the approach in this guide, the only cost is on-chain gas fees for executing swaps. There are no API fees (swapapi.dev is free), no monthly subscriptions, and no per-transaction percentage fees. Gas costs vary by chain: Ethereum mainnet may cost $2-10 per swap, while Layer 2 chains like Arbitrum or Base typically cost under $0.10.
Which chains should I support for a crypto payment gateway?
Start with the chains where your customers hold assets. Ethereum (chainId 1), Arbitrum (42161), and Base (8453) cover most DeFi users. Add BSC (56) for Southeast Asian markets and Polygon (137) for low-cost transactions. swapapi.dev supports 46 chains, so you can expand coverage by adding chain configs without changing any swap logic.
How do I handle refunds in a crypto payment system?
Refunds are separate transactions from the merchant wallet back to the customer's address. Store the customer's wallet address and payment token at checkout. For refunds, use the same swap API in reverse: swap the settlement stablecoin back to the customer's original token and send it to their address.
Get Started
swapapi.dev is a free DEX aggregator API with no API keys, no registration, and support for 46 EVM chains. Get a swap quote with a single GET request and receive executable calldata ready for on-chain submission.
-
API base URL:
https://api.swapapi.dev - OpenAPI spec: api.swapapi.dev/openapi.json
- Interactive docs: api.swapapi.dev/docs
- Full documentation: swapapi.dev
Top comments (0)