API Documentation

Accept Crypto Payments With Zero Blockchain Knowledge

Coinley handles all the complexity of crypto payments, automatic fee splitting, wallet management, and real-time verification, so you can focus on building.

Blockchain Network Illustration
What Coinley does

How we help business
reach a global audience

Coinley helps by helping merchants & business owners reach a global audience by accepting stablecoins anywhere they sell online.

For
Developers

Coinley is a payment processor API that lets you accept cryptocurrency payments (USDT, USDC, etc) without learning blockchain development, smart contracts, or Web3.

  • No blockchain experience needed.
  • Simple Rest API Integration
  • Automatic smart contract interaction
  • Built-in payment verification

For Your Customers

Your customers connect their crypto wallet (Metamask, Coinbase Wallet, etc), approve the transaction and payment is processed automatically.

  • Works with any Web3 wallet
  • Secure, non-custodial payments
  • Instant Settlement
  • Supports 8+ blockchain networks

Real World Example

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 implementation

Supported Networks &
Stablecoins

Use Polygon or BSC for transactions under $100 to minimize gas fees (~$0.01 vs $3-5 on Ethereum/Optimism)

Ethereum

High Fees

Polygon

~0.01 fees

Base

~0.03 fees

OP

Optimism

~0.60 fees

Arbitrum

~$0.50 fee

Avalanche

Low fees

Currency Information

Official Currency &
Conversion APIs

Important: USD is the Official Currency

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.

Recommended Currency Conversion APIs

CoinMarketCap API

Industry-standard for crypto and fiat currency rates with extensive coverage.

View Documentation

ExchangeRate-API

Simple and reliable fiat currency conversion with a generous free tier.

View Documentation

Open Exchange Rates

Accurate exchange rates for 200+ currencies with hourly updates.

View Documentation

Example: Converting GBP to USD

Here'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...' }
});

Pro Tip: Cache Exchange Rates

Exchange rates don't change frequently. Consider caching rates for 1-5 minutes to reduce API calls and improve performance.

SDK Integration (Recommended).

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

Understanding Coinley's Payment Flow

Here's exactly what happens when a customer pays with cryptocurrency using Coinley. We handle the blockchain complexity - you just call our API.

01 02 03

Create Payment

Your backend calls Coinley API to create payment request

Connect Wallet

Customer connects MetaMask or any other crypto Wallet to your site

Execute & Verify

Payment executes via smart contract, Coinley verifies on-chain

Step 01

Create Payment Request.

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
Step 02

Connect Wallet & Switch Network

Your frontend uses Web3.js to connect the customer's crypto wallet and switch to the correct blockchain network:

Connect Wallet

Use window.ethereum.request() to connect MetaMask, Coinbase Wallet, or any Web3 wallet.

Switch Network

Use wallet_switchEthereumChain to switch to Polygon, BSC, Optimism, etc.

Step 03

Execute Payment Transaction

The customer approves and executes the payment. Coinley uses smart contracts to automatically split funds:

Why Two Wallet Popups?

Customers will see two MetaMask popups. This is normal and required for ERC20 token payments:

  1. 1. Approve Transaction: Give the smart contract permission to spend tokens (security feature)
  2. 2. Execute Payment: The smart contract transfers tokens and splits them automatically
99%

Goes to your wallet

1%

Coinley Platform Fee

100%

Automatic Split

Step 04

Verify Payment On-chain

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:

  • Verify transaction exists on blockchain
  • Confirm correct amount was sent
  • Validate correct recipient addresses
  • Mark payment as completed
POST /api/payments/process Verifies transaction on-chain

Prerequisites

  • Coinley API Credentials

    Sign up at Coinley to get your API key and secret

  • Basic Javascript/HTML Knowledge

    You don't need blockchain experience

  • Web3 Libraries

    We'll use Web3.js (auto-loaded from CDN) and Axios

Installation

<!-- 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>

Configuration

Set up your Coinley API credentials. Keep these secure on your backend.

Security Warning

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 = {};

Load Available Networks & Tokens

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('');
}

Step 1: Create Payment

Call the Coinley API to create a payment request. You'll get back smart contract details and fee split configuration.

Post /api/payments/create

SportsPass Example

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...
    }
}

What you get back

{
  "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"
  }
}

What's happening:

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.

Step 2: Connect Wallet & Switch Network

Connect the user's crypto wallet (MetaMask, Coinbase Wallet, etc.) and switch to the correct blockchain network.

Connect Wallet

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;
}

Switch to Correct Network

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;
    }
}

Balance Validation (Critical!)

Always check if the user has enough tokens BEFORE attempting payment. This prevents confusing blockchain errors.

Common Error

"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;
}

Step 3: Execute Payment

Execute the smart contract payment. This involves two transactions: approve and pay.

Why Two Transactions?

  1. 1. Approve: Give the smart contract permission to spend your tokens (ERC20 security standard)
  2. 2. Pay: The contract transfers tokens and automatically splits payment to merchant + Coinley

This is a security feature of ERC20 tokens - contracts can't take your tokens without explicit permission.

Complete Payment Function from SportsPass

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);
}

Step 4: Verify Payment

Send the transaction hash to Coinley for verification. We check the blockchain to confirm payment.

Post /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);
}

Verification Response

{
  "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
  }
}

What Coinley Verifies

Transaction exists on blockchain, correct amount was sent, correct recipients received funds, and fee split was executed correctly.

Gas Fee Optimization

Minimize transaction costs for your users with these strategies (as implemented in SportsPass).

Common Error

"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

Low Fee Networks

Great for frequent transactions, everyday use, and cost-effective interactions.

  • Polygon: $0.50-$0.70 per transaction
  • BSC: $0.40-$0.60 per transaction
  • Base: $10-$30+ per transaction

⚠️ Higher Fee Networks

Best for high-security operations, complex smart contracts, or premium transactions.

  • Optimism: $0.50-$0.70 per transaction
  • Arbitrum: $0.40-$0.60 per transaction
  • Ethereum: $10-$30+ per transaction

Show Fee Warnings to Users

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');
    }
});

Optimism/Arbitrum Direct Transfer Optimization

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.

Error Handling

Handle common errors gracefully to improve user experience.

"User rejected transaction"

User clicked "reject" in wallet popup

Show: "Payment cancelled. No charges were made."

"Insufficient funds"

User doesn't have enough tokens

Show: "Insufficient USDT balance. You need more USDT."

"Network mismatch"

User is on wrong blockchain network

Show: "Please switch to Polygon network in your wallet."

Complete Error Handling

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);
    }
}

Complete Working Example: SportsPass

Here's the complete ticket sales platform built with Coinley. This is a real, production-ready implementation.

SportsPass: Crypto Ticket Sales Platform

A complete e-commerce platform for selling sports tickets with cryptocurrency payments. Features include:

  • Shopping cart functionality
  • Real-time balance checking
  • Payment status tracking
  • Multi-network support
  • Gas fee warnings
  • Error handling
Open a Live Demo

Key Implementation Highlights

01

Network Selection with fee Indicators

See loadNetworks() function - Line 246

Automatically sorts networks by cost-effectiveness and shows visual fee indicators (💚 Low fees, ⚠️ Higher fees)

02

Dynamic Gas Fee Warnings

Refer to setUserPreferences() - Line 312

Calculates and displays estimated fees as a percentage of transaction value

03

Optimism Direct Transfer Optimization

Check monitorPerformance() - Line 178

Reduces Optimism fees by 90% using direct transfer instead of splitter contract

04

Real-time Payment Status UI

See updateStep() and payment modal - Line 932

Shows users exactly what's happening: approve tokens → execute payment → verify

API Reference

SportsPass displays warnings when users select high-fee networks. Here's how:

Post /api/payments/create

Create a new payment request

Request Body

{
  "amount": "10.00",
  "currency": "USDT",
  "network": "polygon",
  "customerEmail": "user@example.com",
  "orderId": "ORDER_123",
  "metadata": {
    "items": [...],
    "platform": "YourApp"
  }
}

Headers Required

X-Public-Key: pk_your_public_key
Content-Type: application/json

Response

{
  "success": true,
  "payment": {
    "id": "uuid",
    "amount": "10.00",
    "contractAddress": "0x...",
    "merchantWallet": "0x...",
    "merchantPercentage": 9900
  }
}
Post /api/payments/process

Create a new payment request

Request Body

{
  "paymentId": "uuid",
  "transactionHash": "0xabc...",
  "network": "polygon",
  "senderAddress": "0x123...",
  "isDirectTransfer": false
}

Response

{
  "success": true,
  "message": "Payment processed successfully",
  "payment": {
    "status": "completed",
    "transactionHash": "0xabc..."
  }
}
Get /api/networks

Get list of supported blockchain networks

Response

{
  "success": true,
  "networks": [
    {
      "id": "1",
      "name": "Polygon",
      "shortName": "polygon",
      "chainId": "137",
      "isTestnet": false
    }
  ]
}
Get /api/networks/:networkId/tokens

Get supported tokens for a specific network

Response

{
  "success": true,
  "tokens": [
    {
      "symbol": "USDT",
      "name": "Tether USD",
      "contractAddress": "0x...",
      "decimals": 6
    }
  ]
}
Get /api/payments/public/:paymentId

Check payment status publicly (no authentication required)

Public Endpoint: No API credentials needed. Perfect for polling payment status from frontend.

Response

{
  "success": true,
  "payment": {
    "id": "uuid",
    "amount": "10.00",
    "status": "completed",
    "Network": {
      "name": "Polygon",
      "chainId": "137"
    }
  }
}

Webhooks

Receive real-time notifications when payment events occur. Webhooks are sent to the URL configured in your merchant dashboard.

Webhook URL vs SDK Callbacks

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.

Setting Up Webhooks

  1. 1

    Log in to your Coinley Dashboard

  2. 2

    Navigate to Settings → Webhooks

  3. 3

    Enter your webhook URL (must be HTTPS in production)

    https://your-backend.com/api/webhooks/coinley
  4. 4

    Copy your Webhook Secret - you'll need this to verify signatures

Webhook Events

payment_success

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"
}
payment_failure

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"
}
deposit.detected deposit.confirmed deposit.swept

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..."
}

Verifying Webhook Signatures

All webhooks include a signature header to verify authenticity. Always verify signatures before processing webhooks.

Security Warning

Never trust webhook data without verifying the signature. Attackers could send fake webhooks to your endpoint.

Webhook Headers

X-Webhook-Signature: sha256=abc123...
X-Webhook-Timestamp: 1705661400000
X-Webhook-Event: payment_success
Content-Type: application/json

Node.js Verification Example

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 });
    }
});

Webhook Best Practices

Respond Quickly

Return a 200 response within 10 seconds. Process webhook data asynchronously if needed.

Handle Duplicates

Webhooks may be retried. Use transactionId as idempotency key to prevent double-processing.

Use HTTPS

Webhook URLs must use HTTPS in production. HTTP is only allowed for local development.

Log Everything

Log all webhook events for debugging. Coinley retries failed webhooks up to 5 times with exponential backoff.