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 Solana, Polygon, or Base for transactions under $100 to minimize fees (~$0.001-0.03 vs $3-5 on Ethereum)

Ethereum

High Fees

Polygon

~0.01 fees

Base

~0.03 fees

Optimism

~0.60 fees

Arbitrum

~$0.50 fee

Avalanche

Low fees

Solana

~$0.001 fees

Monad

Ultra low fees

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.

# Install coinley-pay and required peer dependencies

npm install coinley-pay wagmi viem

# or with yarn

yarn add coinley-pay wagmi viem

Note: If you encounter peer dependency conflicts, create a .npmrc file in your project root with:

legacy-peer-deps=true

Next.js Users: SSR Compatibility

The payment component uses browser APIs and must be dynamically imported with SSR disabled:

import dynamic from 'next/dynamic';

// Dynamically import to avoid SSR issues
const RedesignedCoinleyPayment = dynamic(
  () => import('coinley-pay').then(mod => mod.RedesignedCoinleyPayment),
  { ssr: false }
);

// PaymentAPI can be imported normally
import { PaymentAPI } from 'coinley-pay';
import 'coinley-pay/dist/style.css';

Multi-Currency Support

You can accept payments in local currencies. The SDK automatically converts to USD for crypto payment:

config={{
    amount: 50,        // Amount in local currency
    currency: 'GBP',      //  ZAR, EUR, GBP, supported
    // ... other config
}}

Supported currencies: USD,ZAR, EUR, GBP

TypeScript Support

For TypeScript projects, create a type declaration file src/types/coinley-pay.d.ts:

declare module 'coinley-pay' {
  import { ComponentType } from 'react';

  export interface PaymentConfig {
    amount: number;
    currency?: string;
    customerEmail?: string;
    merchantName?: string;
    merchantWalletAddresses?: Record<string, string>;
    metadata?: Record<string, unknown>;
  }

  export interface RedesignedCoinleyPaymentProps {
    publicKey: string;
    apiUrl: string;
    config: PaymentConfig;
    onSuccess: (paymentId: string, txHash: string, details: unknown) => void;
    onError: (error: string) => void;
    onClose: () => void;
    isOpen: boolean;
    theme?: 'light' | 'dark';
  }

  export const RedesignedCoinleyPayment: ComponentType<RedesignedCoinleyPaymentProps>;

  export class PaymentAPI {
    constructor(apiUrl: string, publicKey: string);
    api: { get: (url: string) => Promise<{ data: any }> };
  }
}

declare module 'coinley-pay/dist/style.css';

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

Complete reference for all Coinley payment API endpoints.

POST /api/payments/create

Create a new payment request. Returns a payment object with contract details for on-chain execution.

Request Body

{
  "amount": "10.00",               // Required — payment amount
  "currency": "USDT",              // Required — USDT, USDC, DAI, or BUSD
  "network": "bsc",                // Required — blockchain network
  "customerEmail": "user@email.com", // Optional — customer email
  "orderId": "ORDER_123",          // Optional — your order reference
  "callbackUrl": "https://api.yoursite.com/webhook/coinley",  // Optional — webhook URL
  "metadata": {                    // Optional — custom data returned in webhooks
    "userId": "user-abc",
    "productName": "Premium Plan",
    "internalRef": "REF-001"
  }
}

Parameters

Field Type Required Description
amount string Yes Payment amount (max 6 decimal places)
currency string Yes Token symbol: USDT, USDC, DAI, or BUSD
network string Yes Blockchain network: ethereum, bsc, polygon, optimism, arbitrum, avalanche, base, solana
callbackUrl string No HTTPS URL to receive webhook notifications for payment success and failure events
customerEmail string No Customer email address
orderId string No Your order reference (max 100 chars)
metadata object No Custom key-value data returned in webhook payloads

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 & Callback URL

Receive real-time server-to-server notifications for both successful and failed payments. Coinley sends webhook events to the callbackUrl you provide when creating a payment.

Webhooks vs SDK Callbacks

Webhooks (callbackUrl) are server-to-server HTTP POST notifications sent to your backend. Use these to update your database, fulfill orders, and track payment status. You receive webhooks for both successful and failed payments.

SDK callbacks (onSuccess, onError) are frontend-only events for updating your UI. Always verify payments via webhooks before fulfilling orders — never rely solely on frontend callbacks.

Setting Up Webhooks

  1. 1

    Pass a callbackUrl when creating a payment (via the SDK config or the POST /api/payments/create request body)

    https://api.yoursite.com/webhook/coinley
  2. 2

    Include any custom data in the metadata object — it will be returned in every webhook event

  3. 3

    Copy your Webhook Secret from your Merchant Dashboard → Settings — you'll need this to verify webhook signatures

  4. 4

    Coinley will POST to your callbackUrl for all payment events — success, failure, deposit confirmation, and sweep completion

Important

If you do not provide a callbackUrl, no webhooks will be sent. The callbackUrl must be a valid HTTPS URL in production. HTTP is allowed for local development only.

Callback URL Integration

How It Works

1. Include callbackUrl in your payment configuration (SDK config or API request body)
2. Add any custom tracking data in the metadata object (orderId, userId, etc.)
3. Coinley POSTs to your callback URL on every payment event — both success and failure
4. Your custom metadata is included in every webhook payload so you can match it to your records

Method 1: SDK Integration

Pass callbackUrl in the SDK payment configuration:

<CoinleyCheckout
  apiKey="pk_live_your_public_key"
  apiSecret="your_api_secret"
  amount={99.99}
  currency="USDT"
  customerEmail="customer@example.com"

  // Coinley will POST to this URL on payment success AND failure
  callbackUrl="https://api.yoursite.com/webhook/coinley"

  // Custom metadata — returned in every webhook payload
  metadata={{
    orderId: 'ORD-12345',
    userId: 'user-abc',
    productName: 'Premium Plan',
    internalRef: 'REF-2026-001'
  }}

  onSuccess={(data) => console.log('Payment succeeded:', data)}
  onError={(error) => console.log('Payment failed:', error)}
/>

Method 2: Direct API Call

Include callbackUrl in the POST /api/payments/create request body:

const response = await fetch('https://hub.coinley.io/api/payments/create', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': 'your_api_key',
    'x-api-secret': 'your_api_secret'
  },
  body: JSON.stringify({
    amount: 99.99,
    currency: 'USDT',
    network: 'bsc',
    customerEmail: 'customer@example.com',

    // Webhook URL — receives both success and failure events
    callbackUrl: 'https://api.yoursite.com/webhook/coinley',

    // Custom data returned in every webhook
    metadata: {
      orderId: 'ORD-12345',
      userId: 'user-abc',
      internalRef: 'REF-2026-001'
    }
  })
});

Success Webhook Payload

When payment succeeds, Coinley sends a POST request to your callbackUrl:

{
  "event": "payment_success",
  "merchantId": "uuid-here",
  "transactionId": "txn-uuid-here",
  "amount": "99.99",
  "currency": "USDT",
  "tokenSymbol": "USDT",
  "network": "bsc",
  "txHash": "0xabc123def456...",
  "senderAddress": "0x742d35Cc6634...",
  "merchantWallet": "0xYourWallet...",
  "status": "completed",
  "metadata": {
    "orderId": "ORD-12345",
    "userId": "user-abc",
    "internalRef": "REF-2026-001"
  },
  "timestamp": "2026-03-01T10:30:00.000Z"
}

Failure Webhook Payload

When payment fails or expires, Coinley sends a POST request to your callbackUrl:

{
  "event": "payment_failure",
  "merchantId": "uuid-here",
  "transactionId": "txn-uuid-here",
  "reason": "Payment not completed within 10 minutes",
  "metadata": {
    "orderId": "ORD-12345",
    "userId": "user-abc",
    "internalRef": "REF-2026-001"
  },
  "timestamp": "2026-03-01T10:40:00.000Z"
}

Webhook Events

payment_success

Sent when a payment is successfully completed and verified on-chain. This is the primary event you should use to fulfill orders.

{
  "event": "payment_success",
  "merchantId": "uuid",
  "transactionId": "uuid",
  "amount": "49.99",
  "currency": "USDT",
  "tokenSymbol": "USDT",
  "network": "bsc",
  "txHash": "0xabc123...",
  "senderAddress": "0x742d35Cc...",
  "merchantWallet": "0xYourWallet...",
  "status": "completed",
  "metadata": {
    "orderId": "your-order-id",
    "userId": "user-abc",
    "internalRef": "REF-2026-001"
  },
  "timestamp": "2026-03-01T10:30:00.000Z"
}
payment_failure

Sent when a payment fails, is rejected, or expires. Payments that are not completed within 10 minutes are automatically marked as failed and this event is triggered.

{
  "event": "payment_failure",
  "merchantId": "uuid",
  "transactionId": "uuid",
  "reason": "Payment not completed within 10 minutes",
  "metadata": {
    "orderId": "your-order-id",
    "userId": "user-abc",
    "internalRef": "REF-2026-001"
  },
  "timestamp": "2026-03-01T10:40:00.000Z"
}

Common failure reasons:

  • Payment not completed within 10 minutes — automatic expiry
  • Transaction verification failed — on-chain verification returned an error
  • Insufficient amount received — customer sent less than required
  • Invalid transaction hash — the submitted tx hash could not be verified
  • Payment cancelled by user — customer explicitly cancelled the payment

Note: Payments automatically expire after 10 minutes. When this happens, a payment_failure webhook is sent to your callbackUrl so you can update the order status in your system.

deposit.confirmed deposit.swept deposit.failed

Events for the "Transfer to Wallet Address" payment flow. When a customer pays by sending crypto directly to a generated deposit address, you receive these events as the payment progresses through confirmation and sweep stages.

// deposit.confirmed — Deposit reached required blockchain confirmations
{
  "event": "deposit.confirmed",
  "paymentId": "uuid",
  "depositAddress": "0xGeneratedAddress...",
  "confirmations": 20,
  "amount": "49.99",
  "txHash": "0xabc123...",
  "timestamp": "2026-03-01T10:35:00.000Z"
}

// deposit.swept — Funds transferred from deposit address to your wallet
{
  "event": "deposit.swept",
  "paymentId": "uuid",
  "depositAddress": "0xGeneratedAddress...",
  "amount": "49.99",
  "sweepTxHash": "0xdef456...",
  "merchantWallet": "0xYourWallet...",
  "timestamp": "2026-03-01T10:36:00.000Z"
}

// deposit.failed — Deposit or sweep failed
{
  "event": "deposit.failed",
  "paymentId": "uuid",
  "depositAddress": "0xGeneratedAddress...",
  "reason": "Sweep transaction failed after max retries",
  "timestamp": "2026-03-01T10:40:00.000Z"
}

Tip: For deposit address payments, you will also receive a payment_success event when the deposit is confirmed. The deposit.swept event indicates the funds have been transferred to your merchant wallet.

Payment Lifecycle & Webhook Timeline

Depending on the payment method, your callbackUrl receives different sequences of events:

Wallet Connect (PaymentSplitter)

pending payment_success or payment_failure

Transfer to Wallet Address (Deposit)

pending deposit.confirmed payment_success deposit.swept

If the deposit or sweep fails, you receive deposit.failed and/or payment_failure instead.

Retry & Delivery

  • Failed webhook deliveries are retried up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s)
  • Your endpoint must respond with a 2xx status code within 10 seconds to be considered successful
  • 4xx responses are not retried (considered a client error)
  • 5xx responses and timeouts trigger retries
  • All webhook attempts (success and failure) are logged and visible in your Merchant Dashboard

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 — handles ALL Coinley events
app.post('/webhook/coinley', express.json(), async (req, res) => {
    try {
        const signature = req.headers['x-webhook-signature'];
        const timestamp = req.headers['x-webhook-timestamp'];
        const event = req.headers['x-webhook-event'];

        // Step 1: Verify signature to prevent spoofed webhooks
        verifyWebhookSignature(req.body, signature, timestamp);

        // Step 2: Extract your custom metadata
        const { orderId, userId } = req.body.metadata || {};

        // Step 3: Handle each event type
        switch (event) {
            case 'payment_success':
                // Payment verified on-chain — fulfill the order
                console.log(`Payment success for order ${orderId}:`, req.body.txHash);
                await updateOrderStatus(orderId, 'paid', {
                    txHash: req.body.txHash,
                    amount: req.body.amount,
                    network: req.body.network
                });
                break;

            case 'payment_failure':
                // Payment failed or expired — cancel/revert the order
                console.log(`Payment failed for order ${orderId}:`, req.body.reason);
                await updateOrderStatus(orderId, 'failed', {
                    reason: req.body.reason
                });
                break;

            case 'deposit.confirmed':
                // Deposit address payment confirmed (funds received)
                console.log(`Deposit confirmed for order ${orderId}`);
                await updateOrderStatus(orderId, 'paid');
                break;

            case 'deposit.swept':
                // Funds swept to your merchant wallet
                console.log(`Deposit swept for order ${orderId}:`, req.body.sweepTxHash);
                break;

            case 'deposit.failed':
                // Deposit or sweep failed
                console.log(`Deposit failed for order ${orderId}:`, req.body.reason);
                await updateOrderStatus(orderId, 'failed', {
                    reason: req.body.reason
                });
                break;

            default:
                console.log(`Unhandled webhook event: ${event}`);
        }

        // Always respond 200 to acknowledge receipt
        res.status(200).json({ received: true });
    } catch (error) {
        console.error('Webhook verification failed:', error.message);
        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.