Coinley handles all the complexity of crypto payments, automatic fee splitting, wallet management, and real-time verification, so you can focus on building.
Coinley helps by helping merchants & business owners reach a global audience by accepting stablecoins anywhere they sell online.
Coinley is a payment processor API that lets you accept cryptocurrency payments (USDT, USDC, etc) without learning blockchain development, smart contracts, or Web3.
Your customers connect their crypto wallet (Metamask, Coinbase Wallet, etc), approve the transaction and payment is processed automatically.
This documentation uses Sportspass- a live ticket sales platform built with Coinley- as the primary example. You'll see exactly how a production application integrates crypto payments without any blockchain expertise needed.
→View the complete Sportspass implementationUse Polygon or BSC for transactions under $100 to minimize gas fees (~$0.01 vs $3-5 on Ethereum/Optimism)
High Fees
~0.01 fees
~0.03 fees
~0.60 fees
~$0.50 fee
Low fees
Coinley uses United States Dollars (USD) as its official currency. All payment amounts must be passed to the API in USD. If your store uses a different currency (GBP, JPY, EUR, etc.), you'll need to convert to USD before creating a payment.
Industry-standard for crypto and fiat currency rates with extensive coverage.
View DocumentationSimple and reliable fiat currency conversion with a generous free tier.
View DocumentationAccurate exchange rates for 200+ currencies with hourly updates.
View DocumentationHere's how to convert United Kingdom pounds (or any local currency) to USD before creating a Coinley payment:
// Example using ExchangeRate-API (free tier available)
async function convertToUSD(amount, fromCurrency) {
const response = await fetch(
`https://api.exchangerate-api.com/v4/latest/USD`
);
const data = await response.json();
// Get the exchange rate for the source currency
const rate = data.rates[fromCurrency];
// Convert to USD (divide by the rate since rates are USD-based)
const usdAmount = amount / rate;
return usdAmount.toFixed(2);
}
// Usage example: Convert 50 GBP to USD
const gbpAmount = 50;
const usdAmount = await convertToUSD(gbpAmount, 'GBP');
console.log(`${gbpAmount} GBP = $${usdAmount} USD`);
// Now use the USD amount with Coinley
coinley.open({
amount: parseFloat(usdAmount), // Always pass USD to Coinley
customerEmail: 'customer@example.com',
merchantName: 'Your Store',
merchantWalletAddresses: { polygon: '0xYourWallet...' }
});
Exchange rates don't change frequently. Consider caching rates for 1-5 minutes to reduce API calls and improve performance.
Use the Coinley SDK for the fastest and easiest integration. The SDK handles all the complexity including wallet connection, network switching, payment execution, and verification.
npm install coinley-pay
# or
yarn add coinley-pay
import { RedesignedCoinleyPayment } from 'coinley-pay';
import 'coinley-pay/dist/style.css';
Here's a complete example from a real e-commerce checkout:
import { useState, useEffect } from 'react';
import { RedesignedCoinleyPayment, PaymentAPI } from 'coinley-pay';
import 'coinley-pay/dist/style.css';
function CheckoutPage() {
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
const [customerEmail, setCustomerEmail] = useState('');
const [orderTotal] = useState(49.99);
const [merchantWallets, setMerchantWallets] = useState({});
const API_URL = 'https://hub.coinley.io';
const PUBLIC_KEY = 'pk_your_public_key';
// Fetch merchant wallets on mount
useEffect(() => {
const fetchWallets = async () => {
const api = new PaymentAPI(API_URL, PUBLIC_KEY);
const response = await api.api.get('/api/merchants/wallets');
if (response.data?.wallets) {
const walletMap = {};
response.data.wallets.forEach(w => {
if (w.walletAddress) walletMap[w.networkShortName] = w.walletAddress;
});
setMerchantWallets(walletMap);
}
};
fetchWallets();
}, []);
const handleSuccess = (paymentId, transactionHash, paymentDetails) => {
console.log('Payment successful!', { paymentId, transactionHash, paymentDetails });
// Update your order status here
alert('Payment completed successfully!');
};
const handleError = (error) => {
console.error('Payment error:', error);
alert('Payment failed: ' + error);
};
return (
<div>
<button onClick={() => setIsPaymentOpen(true)}>
Pay with Crypto
</button>
<RedesignedCoinleyPayment
publicKey={PUBLIC_KEY}
apiUrl={API_URL}
config={{
amount: orderTotal,
customerEmail: customerEmail,
merchantName: "Your Store Name",
merchantWalletAddresses: merchantWallets,
metadata: {
orderId: "ORDER_123"
}
}}
onSuccess={handleSuccess}
onError={handleError}
onClose={() => setIsPaymentOpen(false)}
isOpen={isPaymentOpen}
theme="light"
/>
</div>
);
}
Add the SDK script to your HTML:
<script src="https://unpkg.com/coinley-pay@latest/dist/coinley-vanilla.min.js"></script>
const coinley = new CoinleyVanilla({
publicKey: 'pk_your_public_key',
apiUrl: 'https://hub.coinley.io',
theme: 'light'
});
document.getElementById('payButton').addEventListener('click', () => {
coinley.open(
{
amount: 49.99,
customerEmail: 'customer@example.com',
merchantName: 'Your Store',
merchantWalletAddresses: {
polygon: '0xYourPolygonWallet...',
bsc: '0xYourBSCWallet...'
}
},
{
onSuccess: (paymentId, txHash) => {
alert('Payment successful! Transaction: ' + txHash);
},
onError: (error) => {
alert('Payment failed: ' + error);
}
}
);
});
Here's a complete working HTML page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Coinley Payment</title>
</head>
<body>
<h1>Buy Product - $49.99</h1>
<input type="email" id="email" placeholder="Your email" />
<button id="payButton">Pay with Crypto</button>
<script src="https://unpkg.com/coinley-pay@latest/dist/coinley-vanilla.min.js"></script>
<script>
const coinley = new CoinleyVanilla({
publicKey: 'pk_your_public_key',
apiUrl: 'https://hub.coinley.io'
});
document.getElementById('payButton').onclick = () => {
coinley.open({
amount: 49.99,
customerEmail: document.getElementById('email').value,
merchantName: 'My Store',
merchantWalletAddresses: { polygon: '0xYourWallet...' },
metadata: { orderId: 'ORDER_123' }
}, {
onSuccess: (id, hash, details) => {
console.log('Payment details:', details);
alert('Success! Transaction: ' + hash);
},
onError: (err) => alert('Error: ' + err),
onClose: () => console.log('Modal closed')
});
};
</script>
</body>
</html>
Here's exactly what happens when a customer pays with cryptocurrency using Coinley. We handle the blockchain complexity - you just call our API.
Your backend calls Coinley API to create payment request
Customer connects MetaMask or any other crypto Wallet to your site
Payment executes via smart contract, Coinley verifies on-chain
Your backend calls POST /api/payments/create with payment details (amount, currency, network). Coinley returns:
Payment ID: Unique identifier for this transaction
Smart Contract Address: Where payment will be sent
Fee Split Details: Merchant (99%) + Coinley (1%) wallets and percentages
Token Details: Contract address and decimals for the selected currency
POST /api/payments/create → Returns payment contract details
Your frontend uses Web3.js to connect the customer's crypto wallet and switch to the correct blockchain network:
Use window.ethereum.request() to connect MetaMask, Coinbase Wallet, or any Web3 wallet.
Use wallet_switchEthereumChain to switch to Polygon, BSC, Optimism, etc.
The customer approves and executes the payment. Coinley uses smart contracts to automatically split funds:
Customers will see two MetaMask popups. This is normal and required for ERC20 token payments:
Goes to your wallet
Coinley Platform Fee
Automatic Split
After the customer completes payment, your frontend sends the transaction hash to Coinley. We verify the payment on the blockchain and update the payment status:
POST /api/payments/process → Verifies transaction on-chain
Sign up at Coinley to get your API key and secret
You don't need blockchain experience
We'll use Web3.js (auto-loaded from CDN) and Axios
<!-- Include required libraries in your HTML -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/web3@1.10.0/dist/web3.min.js"></script>
Set up your Coinley API credentials. Keep these secure on your backend.
In production, store API credentials on your backend. The frontend should call your backend API, not Coinley directly.
// Coinley Configuration
const COINLEY_CONFIG = {
baseURL: 'https://hub.coinley.io',
publicKey: 'pk_your_public_key_here'
};
// Store cart and payment state
let cart = [];
let currentPayment = null;
let web3 = null;
let userAccount = null;
let availableNetworks = [];
let networkTokens = {};
Fetch the list of supported blockchain networks and their tokens from Coinley:
async function loadNetworks() {
const response = await axios.get(`${COINLEY_CONFIG.baseURL}/api/networks`, {
headers: {
'X-Public-Key': COINLEY_CONFIG.publicKey
}
});
availableNetworks = response.data.networks.filter(n => !n.isTestnet);
// Sort networks by cost-effectiveness
const sortedNetworks = availableNetworks.sort((a, b) => {
const costRanking = {
'polygon': 1, 'bsc': 2, 'base': 3,
'arbitrum': 4, 'optimism': 5, 'ethereum': 7
};
return (costRanking[a.shortName] || 99) - (costRanking[b.shortName] || 99);
});
// Populate network dropdown
const networkSelect = document.getElementById('paymentNetwork');
networkSelect.innerHTML = sortedNetworks.map(network => {
const costIndicator = network.shortName === 'polygon' || network.shortName === 'bsc'
? ' 💚 (Low fees)' : network.shortName === 'optimism' ? ' ⚠️ (Higher fees)' : '';
return `<option value="${network.shortName}">${network.name}${costIndicator}</option>`;
}).join('');
}
async function loadTokensForNetwork(networkShortName) {
const network = availableNetworks.find(n => n.shortName === networkShortName);
const response = await axios.get(
`${COINLEY_CONFIG.baseURL}/api/networks/${network.id}/tokens`,
{
headers: {
'X-Public-Key': COINLEY_CONFIG.publicKey
}
}
);
networkTokens[networkShortName] = response.data.tokens;
// Populate currency dropdown
const currencySelect = document.getElementById('paymentCurrency');
currencySelect.innerHTML = response.data.tokens.map(token =>
`<option value="${token.symbol}">${token.symbol} - ${token.name}</option>`
).join('');
}
Call the Coinley API to create a payment request. You'll get back smart contract details and fee split configuration.
/api/payments/create
In the SportsPass ticket platform, this function is called when a customer clicks "Connect MetaMask & Pay" with items in their cart. It creates a payment for the total cart value.
async function connectMetaMaskAndPay() {
const email = document.getElementById('customerEmail').value;
const network = document.getElementById('paymentNetwork').value;
const currency = document.getElementById('paymentCurrency').value;
if (!email || cart.length === 0) {
alert('Please enter your email and add items to cart');
return;
}
const total = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
// Create payment via Coinley API
const response = await axios.post(
`${COINLEY_CONFIG.baseURL}/api/payments/create`,
{
amount: total.toFixed(2), // e.g., "19.99"
currency: currency, // e.g., "USDT"
network: network, // e.g., "polygon"
customerEmail: email,
orderId: `TICKET_${Date.now()}`,
metadata: {
tickets: cart,
platform: 'SportsPass'
}
},
{
headers: {
'X-Public-Key': COINLEY_CONFIG.publicKey,
'Content-Type': 'application/json'
}
}
);
if (response.data.success) {
currentPayment = response.data.payment;
console.log('Payment created:', currentPayment);
// Continue to wallet connection...
}
}
{
"success": true,
"payment": {
"id": "21870d6e-d53d-4e72-8761-e0208a8ceacb",
"amount": "19.99",
"currency": "USDT",
"network": "polygon",
"status": "pending",
"contractAddress": "0x48E967d182F744EdB18AcA7092814f1FE17fcB2d",
"splitterPaymentId": "21870d6e-d53d-4e72-8761-e0208a8ceacb",
"merchantWallet": "0x9d961e093FFeC1577d124Cfe65233fE140E88Fc4",
"coinleyWallet": "0xEe9025Cc02c060C03ba5dba3d19C7ea2e752f44d",
"merchantPercentage": 9900, // 99%
"coinleyPercentage": 100, // 1%
"expiresAt": "2025-10-15T14:30:00.000Z"
}
}
Coinley creates a unique payment ID and tells you which smart contract to use. The contract will automatically split funds 99% to you, 1% to Coinley.
Connect the user's crypto wallet (MetaMask, Coinbase Wallet, etc.) and switch to the correct blockchain network.
async function connectWallet() {
// Check if user has a Web3 wallet installed
if (typeof window.ethereum === 'undefined') {
alert('Please install MetaMask or another Web3 wallet');
window.open('https://metamask.io/download/', '_blank');
return;
}
// Initialize Web3
web3 = new Web3(window.ethereum);
// Request account access
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
if (!accounts || accounts.length === 0) {
throw new Error('No wallet accounts found');
}
userAccount = accounts[0];
console.log('Connected wallet:', userAccount);
return userAccount;
}
Ensure the customer's wallet is on the correct blockchain network for the payment. The wallet will show a popup asking the user to switch networks. This is automatic - you don't need to worry about network configurations.
async function switchToNetwork(networkShortName) {
const network = availableNetworks.find(n => n.shortName === networkShortName);
if (!network) throw new Error('Network not found');
// Convert chainId to hex format (required by MetaMask)
const chainIdHex = '0x' + parseInt(network.chainId).toString(16);
try {
// Ask wallet to switch networks
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: chainIdHex }]
});
console.log(`Switched to ${network.name}`);
} catch (error) {
if (error.code === 4902) {
// Network not added to wallet yet
alert(`Please add ${network.name} to your wallet manually`);
}
throw error;
}
}
Always check if the user has enough tokens BEFORE attempting payment. This prevents confusing blockchain errors.
"ERC20: transfer amount exceeds balance" means the user doesn't have enough tokens.
Solution: Check balance first and show a friendly error message before attempting payment
async function checkTokenBalance(tokenAddress, requiredAmount, decimals) {
// ERC20 standard ABI for balanceOf
const erc20ABI = [{
"constant": true,
"inputs": [{"name": "_owner", "type": "address"}],
"name": "balanceOf",
"outputs": [{"name": "balance", "type": "uint256"}],
"type": "function"
}];
const tokenContract = new web3.eth.Contract(erc20ABI, tokenAddress);
// Get user's token balance
const balanceWei = await tokenContract.methods
.balanceOf(userAccount)
.call();
// Convert to decimal (e.g., USDT uses 6 decimals)
const balanceDecimal = parseFloat(balanceWei) / Math.pow(10, decimals);
console.log(`Balance: ${balanceDecimal} | Required: ${requiredAmount}`);
// Check if sufficient
if (balanceDecimal < requiredAmount) {
const shortfall = (requiredAmount - balanceDecimal).toFixed(2);
throw new Error(
`Insufficient balance!\n\n` +
`Required: ${requiredAmount}\n` +
`Available: ${balanceDecimal.toFixed(2)}\n` +
`Shortfall: ${shortfall}\n\n` +
`Please add more funds to your wallet.`
);
}
return true;
}
Execute the smart contract payment. This involves two transactions: approve and pay.
This is a security feature of ERC20 tokens - contracts can't take your tokens without explicit permission.
async function executePayment() {
const networkShortName = currentPayment.network;
const currency = currentPayment.currency;
const tokenData = networkTokens[networkShortName].find(t => t.symbol === currency);
const decimals = tokenData.decimals || 6;
const amount = parseFloat(currentPayment.amount);
const amountWei = (amount * Math.pow(10, decimals)).toString();
// ERC20 Token ABI
const erc20ABI = [
{
"constant": false,
"inputs": [
{"name": "_spender", "type": "address"},
{"name": "_value", "type": "uint256"}
],
"name": "approve",
"outputs": [{"name": "", "type": "bool"}],
"type": "function"
},
{
"constant": true,
"inputs": [{"name": "_owner", "type": "address"}],
"name": "balanceOf",
"outputs": [{"name": "balance", "type": "uint256"}],
"type": "function"
}
];
const tokenContract = new web3.eth.Contract(erc20ABI, tokenData.contractAddress);
// ✅ CRITICAL: Check balance first
const balance = await tokenContract.methods.balanceOf(userAccount).call();
if (BigInt(balance) < BigInt(amountWei)) {
throw new Error('Insufficient token balance');
}
// ✅ STEP 1: Approve token spending
console.log('Requesting token approval...');
const approveTx = await tokenContract.methods
.approve(currentPayment.contractAddress, amountWei)
.send({ from: userAccount });
console.log('Approved! Tx:', approveTx.transactionHash);
// ✅ STEP 2: Execute payment via splitter contract
const splitterABI = [{
"inputs": [{
"components": [
{"name": "token", "type": "address"},
{"name": "amount", "type": "uint256"},
{"name": "paymentId", "type": "string"},
{"name": "recipient1", "type": "address"},
{"name": "recipient2", "type": "address"},
{"name": "recipient3", "type": "address"},
{"name": "recipient1Percentage", "type": "uint256"},
{"name": "recipient2Percentage", "type": "uint256"},
{"name": "recipient3Percentage", "type": "uint256"}
],
"name": "params",
"type": "tuple"
}],
"name": "splitPayment",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}];
const splitterContract = new web3.eth.Contract(
splitterABI,
currentPayment.contractAddress
);
const splitParams = {
token: tokenData.contractAddress,
amount: amountWei,
paymentId: currentPayment.splitterPaymentId,
recipient1: currentPayment.merchantWallet,
recipient2: currentPayment.coinleyWallet,
recipient3: '0x0000000000000000000000000000000000000000',
recipient1Percentage: currentPayment.merchantPercentage,
recipient2Percentage: currentPayment.coinleyPercentage,
recipient3Percentage: 0
};
console.log('Executing payment...');
const paymentTx = await splitterContract.methods
.splitPayment(splitParams)
.send({ from: userAccount });
console.log('Payment successful! Tx:', paymentTx.transactionHash);
// ✅ STEP 3: Verify payment with Coinley
await verifyPayment(paymentTx.transactionHash);
}
Send the transaction hash to Coinley for verification. We check the blockchain to confirm payment.
/api/payments/process
async function verifyPayment(txHash) {
const response = await axios.post(
`${COINLEY_CONFIG.baseURL}/api/payments/process`,
{
paymentId: currentPayment.id,
transactionHash: txHash,
network: currentPayment.network,
senderAddress: userAccount
},
{
headers: {
'X-Public-Key': COINLEY_CONFIG.publicKey,
'Content-Type': 'application/json'
}
}
);
if (!response.data.success) {
throw new Error(response.data.error || 'Verification failed');
}
console.log('Payment verified!', response.data);
}
{
"success": true,
"message": "Payment processed successfully",
"payment": {
"id": "21870d6e-d53d-4e72-8761-e0208a8ceacb",
"status": "completed",
"amount": "19.99",
"transactionHash": "0xabcdef1234567890...",
"senderAddress": "0x581c333..."
},
"onChainData": {
"blockNumber": 18745123,
"gasUsed": "65432",
"confirmed": true
}
}
Transaction exists on blockchain, correct amount was sent, correct recipients received funds, and fee split was executed correctly.
Minimize transaction costs for your users with these strategies (as implemented in SportsPass).
"ERC20: transfer amount exceeds balance" means the user doesn't have enough tokens.
Solution: Check balance first and show a friendly error message before attempting payment
Great for frequent transactions, everyday use, and cost-effective interactions.
Best for high-security operations, complex smart contracts, or premium transactions.
SportsPass displays warnings when users select high-fee networks. Here's how:
// Show fee warning based on selected network
networkSelect.addEventListener('change', (e) => {
const selectedNetwork = e.target.value;
const networkWarning = document.getElementById('networkWarning');
const highFeeNetworks = ['optimism', 'arbitrum', 'ethereum'];
const total = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
if (highFeeNetworks.includes(selectedNetwork)) {
let estimatedFee = 0;
if (selectedNetwork === 'optimism') {
estimatedFee = 0.61;
networkWarning.innerHTML = `
⚠️ OPTIMISM ALERT: Expected fee ~$${estimatedFee}
(${(estimatedFee / total * 100).toFixed(0)}% of transaction).
Switch to Polygon for ~$0.01 fees.
`;
} else if (selectedNetwork === 'ethereum') {
estimatedFee = 15;
networkWarning.innerHTML = `
🚨 ETHEREUM: Fees typically $10-20.
DO NOT USE for small transactions! Use Polygon instead.
`;
}
networkWarning.classList.remove('hidden');
} else {
networkWarning.classList.add('hidden');
}
});
For Optimism and Arbitrum, SportsPass offers a "direct transfer" option that bypasses the splitter contract, reducing fees by 90%. Set isDirectTransfer: true when verifying payment.
Handle common errors gracefully to improve user experience.
User clicked "reject" in wallet popup
Show: "Payment cancelled. No charges were made."
User doesn't have enough tokens
Show: "Insufficient USDT balance. You need more USDT."
User is on wrong blockchain network
Show: "Please switch to Polygon network in your wallet."
try {
await connectWallet();
await switchToNetwork('polygon');
await executePayment();
alert('✅ Payment successful!');
} catch (error) {
console.error('Payment error:', error);
// User-friendly error messages
if (error.message.includes('User denied')) {
alert('Payment cancelled. No charges were made.');
} else if (error.message.includes('Insufficient')) {
alert(error.message); // Our custom balance error
} else if (error.code === 4902) {
alert('Please add this network to your wallet.');
} else {
alert('Payment failed: ' + error.message);
}
}
Here's the complete ticket sales platform built with Coinley. This is a real, production-ready implementation.
A complete e-commerce platform for selling sports tickets with cryptocurrency payments. Features include:
See loadNetworks() function - Line 246
Automatically sorts networks by cost-effectiveness and shows visual fee indicators (💚 Low fees, ⚠️ Higher fees)
Refer to setUserPreferences() - Line 312
Calculates and displays estimated fees as a percentage of transaction value
Check monitorPerformance() - Line 178
Reduces Optimism fees by 90% using direct transfer instead of splitter contract
See updateStep() and payment modal - Line 932
Shows users exactly what's happening: approve tokens → execute payment → verify
SportsPass displays warnings when users select high-fee networks. Here's how:
/api/payments/create
Create a new payment request
{
"amount": "10.00",
"currency": "USDT",
"network": "polygon",
"customerEmail": "user@example.com",
"orderId": "ORDER_123",
"metadata": {
"items": [...],
"platform": "YourApp"
}
}
X-Public-Key: pk_your_public_key
Content-Type: application/json
{
"success": true,
"payment": {
"id": "uuid",
"amount": "10.00",
"contractAddress": "0x...",
"merchantWallet": "0x...",
"merchantPercentage": 9900
}
}
/api/payments/process
Create a new payment request
{
"paymentId": "uuid",
"transactionHash": "0xabc...",
"network": "polygon",
"senderAddress": "0x123...",
"isDirectTransfer": false
}
{
"success": true,
"message": "Payment processed successfully",
"payment": {
"status": "completed",
"transactionHash": "0xabc..."
}
}
/api/networks
Get list of supported blockchain networks
{
"success": true,
"networks": [
{
"id": "1",
"name": "Polygon",
"shortName": "polygon",
"chainId": "137",
"isTestnet": false
}
]
}
/api/networks/:networkId/tokens
Get supported tokens for a specific network
{
"success": true,
"tokens": [
{
"symbol": "USDT",
"name": "Tether USD",
"contractAddress": "0x...",
"decimals": 6
}
]
}
/api/payments/public/:paymentId
Check payment status publicly (no authentication required)
{
"success": true,
"payment": {
"id": "uuid",
"amount": "10.00",
"status": "completed",
"Network": {
"name": "Polygon",
"chainId": "137"
}
}
}
Receive real-time notifications when payment events occur. Webhooks are sent to the URL configured in your merchant dashboard.
Webhooks are server-to-server notifications sent to your backend URL (configured in merchant dashboard). Use these to update your database and fulfill orders.
SDK callbacks (onSuccess, onError) are frontend events for updating UI. Always verify payments via webhooks before fulfilling orders.
Log in to your Coinley Dashboard
Navigate to Settings → Webhooks
Enter your webhook URL (must be HTTPS in production)
https://your-backend.com/api/webhooks/coinley
Copy your Webhook Secret - you'll need this to verify signatures
Sent when a payment is successfully completed and verified on-chain.
{
"event": "payment_success",
"merchantId": "uuid",
"transactionId": "uuid",
"amount": "49.99",
"currency": "USD",
"tokenSymbol": "USDT",
"network": "polygon",
"txHash": "0xabc123...",
"senderAddress": "0x742d35Cc...",
"merchantWallet": "0xYourWallet...",
"status": "completed",
"timestamp": "2025-01-19T10:30:00.000Z"
}
Sent when a payment fails or expires.
{
"event": "payment_failure",
"merchantId": "uuid",
"transactionId": "uuid",
"reason": "Payment not completed within 10 minutes",
"timestamp": "2025-01-19T10:30:00.000Z"
}
Events for deposit address payments (Transfer to Address flow).
// deposit.detected - Initial deposit detected
{
"event": "deposit.detected",
"paymentId": "uuid",
"depositAddress": "0xGeneratedAddress...",
"amount": "49.99",
"token": "USDT",
"network": "base",
"txHash": "0xabc123..."
}
// deposit.confirmed - Required confirmations reached
{
"event": "deposit.confirmed",
"paymentId": "uuid",
"confirmations": 20,
"requiredConfirmations": 20
}
// deposit.swept - Funds transferred to merchant wallet
{
"event": "deposit.swept",
"paymentId": "uuid",
"sweepTxHash": "0xdef456...",
"merchantWallet": "0xYourWallet..."
}
All webhooks include a signature header to verify authenticity. Always verify signatures before processing webhooks.
Never trust webhook data without verifying the signature. Attackers could send fake webhooks to your endpoint.
X-Webhook-Signature: sha256=abc123...
X-Webhook-Timestamp: 1705661400000
X-Webhook-Event: payment_success
Content-Type: application/json
const crypto = require('crypto');
// Your webhook secret from dashboard
const WEBHOOK_SECRET = 'your_webhook_secret_here';
function verifyWebhookSignature(payload, signature, timestamp) {
// Check timestamp is within 5 minutes (prevent replay attacks)
const maxAge = 5 * 60 * 1000; // 5 minutes
if (Date.now() - parseInt(timestamp) > maxAge) {
throw new Error('Webhook timestamp too old');
}
// Reconstruct payload with timestamp
const payloadWithTimestamp = { ...payload, timestamp: parseInt(timestamp) };
// Sort keys for deterministic serialization
const sortedKeys = Object.keys(payloadWithTimestamp).sort();
const serialized = JSON.stringify(payloadWithTimestamp, sortedKeys);
// Generate expected signature
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(serialized)
.digest('hex');
// Extract signature from header (remove 'sha256=' prefix)
const providedSignature = signature.replace('sha256=', '');
// Constant-time comparison to prevent timing attacks
if (!crypto.timingSafeEqual(
Buffer.from(providedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
)) {
throw new Error('Invalid webhook signature');
}
return true;
}
// Express.js webhook handler
app.post('/api/webhooks/coinley', express.json(), (req, res) => {
try {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const event = req.headers['x-webhook-event'];
// Verify signature
verifyWebhookSignature(req.body, signature, timestamp);
// Handle event
switch (event) {
case 'payment_success':
// Update order status in your database
await updateOrderStatus(req.body.transactionId, 'paid');
break;
case 'payment_failure':
// Handle failed payment
await updateOrderStatus(req.body.transactionId, 'failed');
break;
case 'deposit.confirmed':
// Deposit payment confirmed
await updateOrderStatus(req.body.paymentId, 'paid');
break;
}
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: error.message });
}
});
Return a 200 response within 10 seconds. Process webhook data asynchronously if needed.
Webhooks may be retried. Use transactionId as idempotency key to prevent double-processing.
Webhook URLs must use HTTPS in production. HTTP is only allowed for local development.
Log all webhook events for debugging. Coinley retries failed webhooks up to 5 times with exponential backoff.