Accept crypto payments

Four HTTP calls and one webhook. We handle the crypto, the exchange, and the on-chain waiting — you get a USD-denominated payment link and a signed event when it lands.

Try it in test mode

Sign up free (no credit card), then mint a key prefixed ecp_test_ on Manage → API keys. Every snippet below runs as-is once you paste that key in — copy it with the button on each block. Test mode synthesises addresses and balances, so nothing touches real crypto, and /test-complete drives a sandbox deposit all the way to completed so you can exercise your webhook pipeline end to end.

How it works

  1. You call

    POST a USD amount to /api/crypto/deposits. We return a hosted payment link.

  2. Customer pays

    On the payment page they pick a coin + network and send crypto from any wallet.

  3. We confirm

    We watch the chain. Once confirmed, funds are credited to your balance.

  4. We notify

    A signed deposit.completed webhook hits your server with the metadata you set.

Glossary

Deposit
One payment. Address + USD amount + 60-minute window.
Invoice
Shareable payment link (a payment intent). A deposit is created when the customer picks a coin + network.
Payment token
Public id (pay_…) in the checkout URL. Safe to share.
Webhook
Signed POST we send when payment state changes. Skips polling.
Test mode
Keys prefixed ecp_test_. No real crypto, no balance impact.
Idempotency key
Optional header. Same key + body within 24h returns the cached response.

Test mode needs nothing — start here

With an ecp_test_ key you can run the entire flow below — your first signed deposit.completed webhook in under two minutes — with no exchange connected. Test mode serves a synthetic currency catalog and mints TEST_ addresses, so nothing on-chain is required.

Before going live: connect an exchange

To accept real payments (with ecp_live_ keys) your checkout needs somewhere to receive funds. Connect an exchange API key under Dashboard → Setup and make sure at least one deposit address exists for the coins you want to accept — otherwise a live payment link loads but shows "No payment methods available" (the picker is built from your wallet addresses). Connecting an exchange is free and applies to both Free and Pro. For a live integration, GET /api/crypto/currencies returns exchangeConnected: false with an empty list until one is connected — check it before creating live payments. (Test keys report exchangeConnected: false too, but still have a synthetic catalog — don't gate test flows on it.)

1. Create a payment

POST a USD amount. We return a hosted checkout link — the customer picks their coin + network there.

HTTP method POST /api/crypto/deposits

bash
curl -X POST https://easycryptopay.xyz/api/crypto/deposits \
  -H "Authorization: Bearer ecp_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "amount": 99.99,
    "metadata": { "order_id": "order_12345" }
  }'

Response:

json
{
  "invoiceId": "clx...",
  "paymentToken": "inv_xK9mP2rT",
  "expectedAmountUSD": 99.99,
  "expiresAt": "2026-04-30T10:41:00Z",
  "status": "pending",
  "isTest": true,
  "metadata": { "order_id": "order_12345" },
  "paymentLink": "https://easycryptopay.xyz/xK9m"
}

This is a payment intent — a paymentToken and hosted link, not an on-chain address yet. In production you redirect the customer to paymentLink and they pick a coin. Payment links live 7 days; once a currency is selected a deposit is created and the clock shortens to the 60-minute deposit window.

2. Select a coin + network

The hosted /pay page does this when the customer chooses a currency — it materialises the deposit (address + exact crypto amount) and returns a depositId. Call it directly for a headless checkout, or to drive this test loop to completion. (No API key needed — the payment token is the authorization.)

HTTP method POST /api/pay/:token/create-deposit

bash
curl -X POST https://easycryptopay.xyz/api/pay/pay_xK9mP2rT/create-deposit \
  -H "Content-Type: application/json" \
  -d '{ "currency": "USDT", "networkCode": "TRC20" }'

3. Complete the deposit (test mode)

Test deposits never reach an exchange. POST the depositId from step 2 here to drive it to completed — this fires the full deposit.confirmingdeposit.completed webhook sequence. (A paymentToken also works once a deposit exists; calling it before step 2 returns 409 NO_DEPOSIT_YET.)

HTTP method POST /api/crypto/deposits/:id/test-complete

bash
# Drive the sandbox deposit to completed and fire the full
# deposit.confirming → deposit.completed (+ invoice.paid) webhook sequence.
# Pass the depositId from the previous step (a paymentToken works too once a
# deposit exists):
curl -X POST https://easycryptopay.xyz/api/crypto/deposits/<depositId>/test-complete \
  -H "Authorization: Bearer ecp_test_YOUR_KEY"

4. Handle the webhook

Set a webhook URL in API & Webhooks. On completion we POST a signed event — verify it, then update your order.

javascript
import { verifyWebhookSignature } from "@easycryptopay/node";

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyWebhookSignature({
    rawBody: req.body.toString("utf8"),
    signatureHeader: req.header("X-ECP-Signature") || "",
    secret: process.env.ECP_WEBHOOK_SECRET,
  });
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.event === "deposit.completed") {
    // event.data.metadata.order_id, event.data.receivedAmountUsd, ...
  }
  res.json({ ok: true });
});

That's it

You've received a crypto payment end to end. Switch ecp_test_ for ecp_live_ to go live. The platform fee is 0% on every plan.

Next steps

  • Free vs Pro — what actually differs between the plans (volume, and nothing else).
  • API reference — every endpoint with full parameters and responses.
  • Embed checkout — render the checkout inline on your own site (Pro).
  • Webhooks — event types, retry cadence, signature verification.
  • Node.js SDK — skip the HTTP + HMAC boilerplate.

How the volume limit works in practice

Once a Free business reaches its cap, the endpoints that CREATE a payment — POST /api/invoices, POST /api/crypto/deposits, and the public checkout — return 402 { "code": "PLAN_LIMIT_REACHED", "limit": { … } } . Payments that already exist are never affected: they confirm, complete, and credit as normal. Usage is measured over a rolling window, so headroom returns automatically as older payments age out — the limit.resetsAt field tells you when. Test-mode payments (ecp_test_ keys) are exempt and never counted. Upgrading takes effect immediately.

Embedded checkout vs the hosted link

Both plans can mint payment tokens and redirect customers to /pay/<token> — that hosted page is the universal checkout. Pro adds the ability to render that checkout inside an iframe directly on your own site via embed.js, so customers never leave your domain. See Embed checkout below for the full integration.

OpenAPI spec + Postman

Want a machine-readable contract? The public API is described in OpenAPI 3.1 at easycryptopay.xyz/openapi.json . In Postman: Import → Link → paste that URL (same for Insomnia). It also feeds any OpenAPI client generator. Set your ecp_test_ key as a Bearer token and you can call every Bearer endpoint from Postman.

Endpoint index

Every Bearer-API-key endpoint at a glance. Detail sections follow below; the public /api/pay/* checkout endpoints and the full schemas live in the OpenAPI spec.

POST/api/crypto/depositsCreate a payment
POST/api/pay/:token/create-depositSelect coin+network → mint the deposit (public)
GET/api/crypto/deposits/:id/checkPoll deposit status
POST/api/crypto/deposits/:id/test-completeComplete a test deposit
POST/api/invoicesCreate an invoice
GET/api/invoicesList invoices
GET/api/invoices/:idGet an invoice
GET/api/paymentsList payments
GET/api/payments/:idGet a payment
GET/api/balanceBalance + lifetime stats
GET/api/customersList customers
GET/api/crypto/currenciesSupported coins + networks
PUT/api/crypto/auto-convertToggle auto-convert (Pro)
POST/api/payments/:id/auto-convert/retryRetry a conversion (Pro)
POST/api/crypto/withdrawalsCreate a payout — needs payouts scope
GET/api/crypto/withdrawalsList payouts
POST/api/crypto/withdrawals/:id/approveApprove a payout — needs payouts scope
GET/api/crypto/withdrawals/:id/checkPayout status

Create a deposit

Create a new deposit session. Returns a unique payment address plus a hosted checkout link you can redirect your customer to. The deposit expires 60 minutes after creation.

HTTP method POST /api/crypto/deposits

Request

bash
curl -X POST https://easycryptopay.xyz/api/crypto/deposits \
  -H "Authorization: Bearer ecp_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "amount": 99.99,
    "metadata": { "order_id": "order_12345" }
  }'

Body parameters

FieldTypeDescription
amountrequired number Amount in USD. Converted to the crypto amount at the current market rate once the customer selects a coin on the hosted payment page.
metadata object Free-form JSON. Max 50 keys (each key ≤40 chars), 500 chars per value. Carried through the deposit lifecycle and echoed on every webhook.

Headers

FieldTypeDescription
Idempotency-Key string Optional. Same key + same body within 24 hours returns the cached response. Different body on the same key returns 409.

Response

json
{
  "invoiceId": "clx...",
  "paymentToken": "inv_xK9mP2rT",
  "expectedAmountUSD": 99.99,
  "expiresAt": "2026-04-30T10:41:00Z",
  "status": "pending",
  "feePercent": 0.1,
  "feeUsd": 0.10,
  "isTrial": false,
  "isTest": false,
  "metadata": { "order_id": "order_12345" },
  "paymentLink": "https://easycryptopay.xyz/xK9m"
}

Response fields

FieldTypeDescription
invoiceId string Server-side identifier for the payment.
paymentToken string Public token used in the hosted /pay URL. Works with the /check endpoint.
expectedAmountUSD number USD amount you requested. Also emitted as expectedAmountUsd (camelCase) — prefer that; the all-caps alias is kept for backwards compatibility and will be dropped in a future major version.
expiresAt string (ISO 8601) Payment link expiry — 7 days from creation. Once the customer picks a currency the resulting deposit gets a tighter 60-minute window.
status string Initially pending. Moves to awaiting_confirmation (object field; the matching webhook event is named deposit.confirming) → completed once the customer pays. Can also reach expired or failed.
feePercent number Platform fee rate that will apply on completion. Always 0 — every plan settles at 0%. Retained for backwards compatibility.
feeUsd number Platform fee in USD that will be deducted on completion.
isTrial boolean Deprecated — always false. Retained for backward compatibility.
isTest boolean True if created with a test-mode API key.
metadata object | null The metadata you provided, or null.
paymentLink string Hosted checkout page URL. Redirect your customer here.

IP whitelisting

If you're self-connecting an exchange API key, whitelist 82.29.181.104 in your exchange's API settings so we can fetch deposit history.

Get deposit status

Poll a deposit to check its current state. We recommend a 5-second polling interval — or skip polling entirely and use webhooks .

HTTP method GET /api/crypto/deposits/:id/check

Path parameters

FieldTypeDescription
idrequired string The paymentToken returned by “Create a payment”, or a deposit id/token once a deposit exists. For an amount-only payment, pass the paymentToken — /check reports the payment state (pending until the customer picks a coin + network, then the deposit's status). Note: /check returns status fields, not an id.

Request

bash
curl https://easycryptopay.xyz/api/crypto/deposits/pay_xK9mP2rT/check \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"

Response

json
{
  "status": "completed",
  "confirmations": 1,
  "txId": "a8f3e2b1c4d5...",
  "amountUsd": 100,
  "totalCreditedUsd": 99.90
}

Response fields

FieldTypeDescription
status string One of pending , awaiting_confirmation , completed , expired , or failed .
confirmations number Current confirmation count for the network. The deposit completes once this reaches the network's required confirmations (the confirmationsRequired field on the /pay/<token>/create-deposit response, also visible in List currencies as networks[].minConfirm).
txId string | null Blockchain transaction id once detected.
amountUSD number USD amount detected on-chain.
totalCreditedUSD number USD amount credited to your balance after fees.

Prefer webhooks

Polling is fine for small workloads, but webhooks are lower latency and rate-limit friendlier. If you're building at scale, wire up deposit.completed and skip the polling loop.

Complete a test deposit

Test-mode deposits never reach a real exchange. This endpoint drives a sandbox deposit to completed immediately and fires the full webhook sequence exactly as production would.

HTTP method POST /api/crypto/deposits/:id/test-complete

Test mode only

This endpoint rejects deposits created with a live key ( ecp_live_* ). It is strictly sandbox.

Path parameters

FieldTypeDescription
idrequired string depositId or paymentToken of a deposit created via a test-mode key. Calling this with a live-mode deposit returns 400.

Request

bash
curl -X POST https://easycryptopay.xyz/api/crypto/deposits/clx.../test-complete \
  -H "Authorization: Bearer ecp_test_YOUR_KEY"

Response

json
{
  "status": "completed",
  "txId": "test_tx_abc123",
  "confirmations": 1,
  "amountUsd": 25,
  "totalCreditedUsd": 24.975,
  "isTest": true
}

Response fields

FieldTypeDescription
status string Always completed on success.
txId string Synthetic transaction id prefixed with test_tx_.
confirmations number Echoes confirmationsRequired so chained polling logic settles immediately.
amountUSD number The original expectedAmountUSD from create-deposit.
totalCreditedUSD number Amount credited to your test balance after simulated fees.
isTest boolean Always true.

Test-mode completions fire the same events ( deposit.confirming then deposit.completed ) with isTest: true in the payload so you can filter in your handler. Test deposits are excluded from your balance and volume rollups, so they never affect billing.

Create an invoice

Invoices are shareable payment requests. Create one, and we give you a hosted checkout link you can email the customer — or we can email it for you.

HTTP method POST /api/invoices

Request

bash
curl -X POST https://easycryptopay.xyz/api/invoices \
  -H "Authorization: Bearer ecp_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Website Development",
    "amount_usd": 2500,
    "description": "Phase 1 deliverable",
    "recipient_email": "client@example.com",
    "send": true,
    "metadata": { "project_id": "proj_42" }
  }'

Body parameters

FieldTypeDescription
titlerequired string Shown at the top of the checkout page.
amount_usdrequired number Invoice total in USD.
descriptionrequired string Long-form description shown on the invoice page.
recipient_email string Customer's email. Required if send is true.
send boolean If true, we email the customer a checkout link immediately.
expires_in_days number Days from now until the invoice expires. 1–365, or 0 for no expiry. Default 7. Ignored if expires_at is also provided.
expires_at string (ISO 8601) Specific expiration timestamp. Must be in the future and at most 1 year out. Takes precedence over expires_in_days.
metadata object Free-form JSON. Max 50 keys (each key ≤40 chars), 500 chars per value. Echoed on webhook payloads.

Response

json
{
  "invoice": {
    "id": "clx...",
    "title": "Website Development",
    "payment_link": "https://easycryptopay.xyz/pay/inv_bN3qW7sY",
    "paymentLink": "https://easycryptopay.xyz/bN3q",
    "status": "sent",
    "expires_at": "2026-05-22T10:00:00Z",
    "expiresAt": "2026-05-22T10:00:00Z",
    "is_test": false,
    "isTest": false,
    "metadata": { "project_id": "proj_42" }
  },
  "emailSent": true
}

Invoice object

FieldTypeDescription
id string Invoice identifier.
title string Echoed from the request.
payment_link string Share this URL with your customer.
status string One of draft , sent , paid , or expired .
is_test boolean True if created with a test-mode API key.
metadata object | null Your metadata, or null.

Get an invoice

Fetch a single invoice by its id or its paymentToken (same as /check). Returns the same object as Create an invoice (every field dual-emitted in both camelCase and snake_case), plus email_status, paid_at, and expires_at. A test-mode key resolves only test invoices; a live key only live ones — mismatches return 404.

HTTP method GET /api/invoices/:id

bash
curl https://easycryptopay.xyz/api/invoices/clx... \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"

List payments

List deposits for your business. Supports filtering by status and currency, plus pagination. Includes aggregate stats across the full result set.

HTTP method GET /api/payments

Query parameters

FieldTypeDescription
status string Filter by deposit status (pending, awaiting_confirmation, completed, expired, failed).
currency string Filter by currency code — e.g., USDT.
limit number default: 50 Max results per page. Range: 1–200.
offset number default: 0 Results to skip, for pagination.

Request

bash
curl "https://easycryptopay.xyz/api/payments?status=completed&limit=50&offset=0" \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"

Response

json
{
  "payments": [
    {
      "id": "clx...",
      "status": "completed",
      "currency": "USDT",
      "network": "TRC20",
      "networkDisplayName": "Tron (TRC20)",
      "amount": 100,
      "amount_usd": 100,
      "expected_amount_usd": 100,
      "expected_crypto_amount": 100,
      "deposit_address": "TXrk4as3waDADP2Q...",
      "deposit_tag": null,
      "tx_id": "a8f3e2b1c4d5...",
      "confirmations": 1,
      "required_confirmations": 1,
      "fee_usd": 0.10,
      "fee_percent": 0.1,
      "is_trial": false,
      "is_test": false,
      "metadata": { "order_id": "order_12345" },
      "payment_link": "https://easycryptopay.xyz/pay/pay_xK9mP2rT",
      "total_amount_usd": 99.90,
      "created_at": "2026-04-22T10:00:00Z",
      "completed_at": "2026-04-22T10:05:22Z",
      "expires_at": "2026-04-22T11:00:00Z"
    }
  ],
  "total": 42,
  "stats": {
    "totalReceived": 15420.50,
    "totalFees": 15.42,
    "completedCount": 38,
    "pendingCount": 4
  }
}

Response fields

FieldTypeDescription
payments Payment[] Matching deposits for this page. Field names are snake_case.
payments[].id string Deposit identifier.
payments[].status string pending, awaiting_confirmation, completed, expired, or failed.
payments[].currency string Coin ticker (e.g. USDT).
payments[].network string Blockchain network code (e.g. TRC20).
payments[].networkDisplayName string Human-readable network name.
payments[].amount number Crypto amount actually received (0 until confirmed).
payments[].amount_usd number USD value of what was received.
payments[].expected_amount_usd number USD amount the merchant requested.
payments[].expected_crypto_amount number Crypto amount the customer was asked to send.
payments[].deposit_address string On-chain destination address.
payments[].deposit_tag string | null Memo/tag for chains that need it (XRP, etc.).
payments[].tx_id string | null Blockchain transaction id once detected.
payments[].confirmations number Current confirmation count.
payments[].required_confirmations number Confirmations required for completion.
payments[].fee_usd number Platform fee in USD.
payments[].fee_percent number Fee rate applied. Always 0 — every plan settles at 0%.
payments[].total_amount_usd number | null Net credited (amount_usd − fee_usd). Null until completed.
payments[].is_trial boolean Deprecated — always false. Retained for backward compatibility.
payments[].is_test boolean True if created with a test API key.
payments[].metadata object | null Free-form JSON you supplied at create time.
payments[].payment_link string Hosted /pay URL for this deposit.
payments[].created_at string (ISO 8601) Creation timestamp.
payments[].completed_at string (ISO 8601) | null Completion timestamp, null until completed.
payments[].expires_at string (ISO 8601) Deposit expiry timestamp.
total number Total matching deposits across all pages.
stats.totalReceived number Sum of receivedAmountUsd across matching completed deposits.
stats.totalFees number Sum of fee_usd across matching completed deposits.
stats.completedCount number Number of matching deposits with status completed.
stats.pendingCount number Number of matching deposits with status pending or awaiting_confirmation.

Get a payment

Fetch a single deposit by its id or its paymentToken — the same row shape as List payments, plus an autoConvert sub-object (Pro). For a lighter status check, prefer /check ; this endpoint returns the full record. Same test/live visibility rule as the list.

HTTP method GET /api/payments/:id

bash
curl https://easycryptopay.xyz/api/payments/clx... \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"

Get balance

Current balance plus lifetime volume, fees collected, and pending deposit count. Useful for dashboards, billing reconciliation, and webhook-independent polling.

HTTP method GET /api/balance

Request

bash
curl https://easycryptopay.xyz/api/balance \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"

Response

json
{
  "balance": 1542.30,
  "totalVolumeUsd": 15420.50,
  "totalReceived": 15425.72,
  "totalFees": 5.42,
  "pendingCount": 2,
  "currency": "USD"
}

Response fields

FieldTypeDescription
balance number Current settled balance in USD.
totalVolumeUsd number Lifetime completed deposit volume.
totalReceived number Sum of all USD amounts received (before fees).
totalFees number Total platform fees paid to date.
pendingCount number Deposits currently in pending or confirming state.
currency string Always USD.
Test-mode deposits are excluded from every field on this endpoint — balance, volume, and counts all reflect live activity only.

List customers

Everyone who has paid you, with lifetime totals and their five most recent transactions. Filter by status, search by email or name, and paginate with limit + offset. Rows are dual-emitted (camelCase + snake_case). A test key sees test transactions; a live key sees live ones.

HTTP method GET /api/customers

Query parameters

FieldTypeDescription
status string
search string Case-insensitive match on email or name.
limit number default: 50 Max results per page. Range 1–200.
offset number default: 0 Results to skip, for pagination.

Request

bash
curl "https://easycryptopay.xyz/api/customers?status=active&limit=50" \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"

Response

json
{
  "customers": [
    {
      "id": "cus_...",
      "email": "buyer@example.com",
      "name": "buyer",
      "totalPaidUsd": 240.5,
      "transactionCount": 3,
      "lastCurrency": "USDT",
      "lastActivity": "2026-04-22T10:05:22Z",
      "status": "active",
      "createdAt": "2026-03-01T09:00:00Z",
      "transactions": [
        { "id": "dep_...", "amountUsd": 100, "currency": "USDT", "status": "completed", "date": "2026-04-22" }
      ]
    }
  ],
  "total": 128,
  "stats": { "total": 128, "active": 42, "totalRevenue": 15420.5 }
}

Response fields

FieldTypeDescription
customers Customer[] Matching customers for this page (snake_case keys carry camelCase aliases).
customers[].totalPaidUsd number Lifetime net USD this customer has paid (live deposits only).
customers[].transactionCount number Deposits + invoices in the caller's test/live window.
customers[].status string
customers[].transactions Transaction[] Up to 5 most recent, newest first.
total number Total customers matching the filter across all pages.
stats.totalRevenue number Sum of totalPaidUsd across all of your customers.

List currencies

Returns the currencies your connected exchange supports, each with available blockchain networks, minimum confirmations, and current USD pricing. Use this to populate a currency picker or validate user input before creating a deposit.

HTTP method GET /api/crypto/currencies

Request

bash
curl https://easycryptopay.xyz/api/crypto/currencies \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"

Response

json
{
  "currencies": [
    {
      "coin": "USDT",
      "name": "Tether",
      "icon": "/images/crypto/usdt.png",
      "isStablecoin": true,
      "sortOrder": 0,
      "pricePerUnit": 1.0,
      "autoConvertEnabled": false,
      "networks": [
        {
          "code": "TRC20",
          "displayName": "Tron (TRC20)",
          "minConfirm": 1,
          "estimatedTime": "~1 min",
          "walletAddress": "TXrk4as3waDADP2Q...",
          "walletTag": null,
          "isDefault": true,
          "walletGenerated": true,
          "walletCount": 1,
          "wallets": [
            {
              "id": "wlt_...",
              "address": "TXrk4as3waDADP2Q...",
              "tag": null,
              "isDefault": true,
              "createdAt": "2026-04-22T10:00:00Z",
              "exchangeConfigId": "exc_...",
              "exchangeKey": "mexc",
              "exchangeName": "MEXC"
            }
          ]
        }
      ]
    }
  ],
  "exchangeConnected": true,
  "exchangeKey": "mexc",
  "tradingApiAvailable": true,
  "useAutoRanking": false
}

Response fields

FieldTypeDescription
currencies Currency[] Array of supported currencies (USDT, USDC).
currencies[].coin string Ticker — USDT or USDC.
currencies[].name string Human-readable name.
currencies[].icon string Path to the coin icon asset.
currencies[].isStablecoin boolean Always true for the currently supported coins.
currencies[].sortOrder number Display order index used by the picker UI.
currencies[].pricePerUnit number Current USD price per 1 unit of the currency.
currencies[].networks Network[] Networks supported by your connected exchange.
currencies[].networks[].code string Network code (e.g. TRC20, ERC20). Used internally by the hosted /pay page when the customer picks a network.
currencies[].networks[].displayName string Human-readable network name.
currencies[].networks[].minConfirm number Confirmations required before a deposit is complete.
currencies[].networks[].estimatedTime string Rough on-chain settlement time, e.g. \"~1 min\".
currencies[].networks[].walletAddress string Address of the default wallet (legacy alias for wallets[0].address).
currencies[].networks[].walletTag string | null Memo/tag for chains that need it (legacy alias for wallets[0].tag).
currencies[].networks[].isDefault boolean True for the network used as default when none is selected.
currencies[].networks[].walletGenerated boolean True when at least one wallet exists for this network.
currencies[].networks[].walletCount number Number of wallets configured for this network.
currencies[].networks[].wallets Wallet[] All wallets configured for this network, primary first.
currencies[].networks[].wallets[].id string Wallet identifier.
currencies[].networks[].wallets[].address string On-chain address.
currencies[].networks[].wallets[].tag string | null Memo/tag for the wallet.
currencies[].networks[].wallets[].isDefault boolean True for the wallet used by default.
currencies[].networks[].wallets[].createdAt string (ISO 8601) When the wallet was created.
currencies[].networks[].wallets[].exchangeConfigId string | null Exchange config that owns the wallet (null for legacy entries).
currencies[].networks[].wallets[].exchangeKey string | null Lowercase exchange key (e.g. mexc, binance).
currencies[].networks[].wallets[].exchangeName string Display name of the source exchange.
exchangeConnected boolean False if your business has not yet connected an exchange API key. In that case networks will be empty.
exchange string | undefined Display name of the connected exchange. Omitted when exchangeConnected is false.

Setting up

Add a webhook URL in API & Webhooks. A signing secret is auto-generated on first save — use it to verify signatures, rotate any time.

Events

EventDescription
deposit.pendingDeposit initiated, awaiting payment.
deposit.confirmingPayment detected on-chain, waiting for confirmations.
deposit.completedDeposit fully confirmed and credited.
deposit.expiredDeposit window expired without receiving payment.
deposit.failedDeposit failed during processing.
deposit.auto_convertedPro auto-convert finalised — fires once at terminal state (filled / failed / skipped). Payload mirrors deposit.completed plus an autoConvert sub-object.
invoice.paidInvoice fully paid (linked deposit completed).
invoice.expiredInvoice expired without payment.
withdrawal.pendingPayout intent created (payouts only; requires payouts_enabled).
withdrawal.approvedPayout approved and queued for sending.
withdrawal.processingPayout submitted to the exchange / chain.
withdrawal.completedPayout sent on-chain.
withdrawal.failedPayout failed; see data.failureReason.
webhook.testSynthetic test event fired from the dashboard's “Send test” button.

Payload shape

All webhook bodies share the same envelope: an event string and a data object matching the event type.

json
{
  "apiVersion": "1",
  "event": "deposit.completed",
  "timestamp": 1745311354,
  "data": {
    "id": "clx...",
    "paymentToken": "pay_xK9mP2rT",
    "paymentLink": "https://easycryptopay.xyz/xK9m",
    "status": "completed",
    "currency": "USDT",
    "network": "TRC20",
    "networkDisplayName": "Tron (TRC20)",
    "amountUsd": 100,
    "receivedAmountUsd": 100,
    "receivedCryptoAmount": 100,
    "feeUsd": 0.1,
    "confirmations": 1,
    "confirmationsRequired": 1,
    "txId": "a8f3e2b1c4d5...",
    "depositAddress": "TXrk4as3waDADP2Q...",
    "depositTag": null,
    "invoiceId": null,
    "customerEmail": null,
    "createdAt": "2026-04-22T10:00:00Z",
    "expiresAt": "2026-04-22T11:00:00Z",
    "completedAt": "2026-04-22T10:05:22Z",
    "isTest": false,
    "metadata": { "order_id": "order_12345" },
    "autoConvert": { "enabled": false }
  }
}

metadata is echoed verbatim

Whatever you passed to create-deposit or create-invoice shows up unchanged under data.metadata — use it to correlate back to your own order id without extra lookups.

Signature header

Every request includes an X-ECP-Signature header. Stripe-style comma-separated key/value pairs:

http
X-ECP-Signature: t=1776852322,v1=a8f3e2b1c4d5e6f7...

t is the Unix timestamp when we signed the request. v1 is HMAC-SHA256 of `${t}.${rawBody}` using your webhook secret, hex-encoded.

Use the raw body

Signature is over the raw request bytes. If your framework parses JSON before you reach it (e.g., Express's default json middleware), re-serializing will change whitespace and break the check. Receive the raw buffer instead.

Signature verification

Reject any request where the signature doesn't match — anyone who knows your endpoint URL could otherwise spoof payments. Reject requests where abs(now - t) > 300 seconds to defeat replays.

Node.js

The official SDK handles this for you:

javascript
import { verifyWebhookSignature } from "@easycryptopay/node";

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyWebhookSignature({
    rawBody: req.body.toString("utf8"),
    signatureHeader: req.header("X-ECP-Signature") || "",
    secret: process.env.ECP_WEBHOOK_SECRET,
  });
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.event === "deposit.completed") {
    // event.data.metadata.order_id, event.data.receivedAmountUsd, ...
  }
  res.json({ ok: true });
});

Python

python
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{t}.{raw_body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, v1)

PHP

php
<?php
function verify_ecp($raw_body, $header, $secret, $tolerance = 300) {
    $parts = [];
    foreach (explode(',', $header) as $seg) {
        [$k, $v] = array_pad(explode('=', $seg, 2), 2, null);
        if ($k && $v !== null) $parts[trim($k)] = trim($v);
    }
    if (empty($parts['t']) || empty($parts['v1'])) return false;
    if (abs(time() - (int)$parts['t']) > $tolerance) return false;
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $raw_body, $secret);
    return hash_equals($expected, $parts['v1']);
}

Go

code
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "strconv"
    "strings"
    "time"
)

func verify(raw []byte, header, secret string, tolerance int64) bool {
    var t, v1 string
    for _, seg := range strings.Split(header, ",") {
        kv := strings.SplitN(strings.TrimSpace(seg), "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            t = kv[1]
        case "v1":
            v1 = kv[1]
        }
    }
    if t == "" || v1 == "" {
        return false
    }

    ts, err := strconv.ParseInt(t, 10, 64)
    if err != nil {
        return false
    }
    if delta := time.Now().Unix() - ts; delta > tolerance || delta < -tolerance {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(t + "."))
    mac.Write(raw)

    expected, _ := hex.DecodeString(v1)

    // hmac.Equal, not bytes.Equal or ==: constant time.
    return hmac.Equal(mac.Sum(nil), expected)
}

func handler(w http.ResponseWriter, r *http.Request) {
    // io.ReadAll, NOT json.NewDecoder — the signature covers the raw bytes.
    raw, _ := io.ReadAll(r.Body)
    if !verify(raw, r.Header.Get("X-ECP-Signature"), os.Getenv("ECP_WEBHOOK_SECRET"), 300) {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    // json.Unmarshal(raw, &event)
    w.WriteHeader(http.StatusOK)
}

Ruby

code
require "openssl"

def verify_ecp(raw_body, header, secret, tolerance = 300)
  parts = header.to_s.split(",").map { |seg| seg.strip.split("=", 2) }.to_h
  t, v1 = parts["t"], parts["v1"]
  return false if t.nil? || v1.nil?
  return false if (Time.now.to_i - t.to_i).abs > tolerance

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
  # secure_compare, not ==
  OpenSSL.secure_compare(expected, v1)
end

# Rails: request.raw_post is the raw body. params is NOT — it has been parsed
# and re-ordered, and hashing it will never match.
post "/webhook" do
  halt 401 unless verify_ecp(request.body.read, request.env["HTTP_X_ECP_SIGNATURE"], ENV["ECP_WEBHOOK_SECRET"])
  event = JSON.parse(request.body.tap(&:rewind).read)
  status 200
end

C#

code
using System.Security.Cryptography;
using System.Text;

static bool VerifyEcp(string rawBody, string header, string secret, int tolerance = 300)
{
    string? t = null, v1 = null;
    foreach (var seg in (header ?? "").Split(','))
    {
        var kv = seg.Trim().Split('=', 2);
        if (kv.Length != 2) continue;
        if (kv[0] == "t") t = kv[1];
        if (kv[0] == "v1") v1 = kv[1];
    }
    if (t is null || v1 is null) return false;
    if (!long.TryParse(t, out var ts)) return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > tolerance) return false;

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
    var expected = Convert.ToHexString(digest).ToLowerInvariant();

    // FixedTimeEquals, not string ==
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(v1));
}

// ASP.NET Core: EnableBuffering() then read the body as a string. Binding the
// request to a model gives you a re-serialised object, which will not verify.

Three ways to get this wrong

In the order people get them wrong. One: hashing a re-encoded body. Hash the raw bytes — JSON.stringify(req.body) re-orders keys and changes whitespace, and it will never match. Two: comparing with ==, which returns early on the first differing byte and leaks how much of a guess was right; use your language's constant-time compare. Three: ignoring t, which makes a signature captured once valid forever.

Retries & replay

Endpoint is down for maintenance? Don't worry. We retry automatically with exponential backoff, persist every attempt, and give you a manual replay button so nothing ever slips through the cracks.

What triggers a retry

  • HTTP response outside 2xx (e.g., 500, 502, 503).
  • Request timeout — we wait up to 10 seconds for a response.
  • Connection refused or DNS failure.

Retry schedule

Attempt Delay after previous
InitialImmediate
Retry 1+30 seconds
Retry 2+2 minutes
Retry 3+10 minutes
Retry 4+1 hour
Retry 5+6 hours

Total window: ~7 hours across 6 attempts. Each delay carries up to ±20% random jitter, so the exact timing varies slightly from the values above (this spreads load when many endpoints recover at once).

Delivery log

Every attempt — successful or not — is persisted. For each delivery you can see:

  • Timestamp and attempt number.
  • HTTP status code and response body (truncated to a safe length).
  • Full request headers and signed payload, exactly as we sent it.
  • The event type and referenced deposit/invoice id.

Manual replay

Any past delivery can be re-sent from the dashboard. The replay carries the same payload (so your idempotent handler does the right thing) but with a fresh timestamp and signature, so your verification code still passes.

Make your handler idempotent

Webhooks can arrive twice in rare cases (we retry on transient errors, and a slow handler can ACK after we've already scheduled a retry). De-duplicate on (event, data.id) — a single deposit emits several lifecycle events that all share the same data.id, so keying on the id alone would drop legitimate state changes. Drop duplicates server-side.

Test deliveries

From the webhook settings page you can fire a synthetic webhook.test event without creating a deposit. Use it to confirm end-to-end wiring before going live.

Install

bash
npm install @easycryptopay/node
# or
yarn add @easycryptopay/node
# or
pnpm add @easycryptopay/node

Methods

One typed method per endpoint — the client mirrors the REST surface you can reach with a Bearer key:

  • createDeposit · createInvoice — mint a payment or invoice.
  • getDepositStatus (poll /check by id or token) · completeTestDeposit (drive a sandbox deposit to completed).
  • getInvoice · getPayment — fetch a single record.
  • listInvoices · listPayments · listCustomers — paginated lists (limit/offset).
  • getBalance · listCurrencies — balance + supported coins/networks.
  • verifyWebhookSignature — standalone HMAC verifier (named export; no client needed).

Creating a deposit

javascript
import EasyCryptoPay from "@easycryptopay/node";

const client = new EasyCryptoPay({ apiKey: process.env.ECP_API_KEY });

// POST a USD amount. Customer picks currency + network on the hosted /pay page.
const payment = await client.createDeposit({
  amount: 99.99,
  metadata: { order_id: "order_12345" },
  idempotencyKey: "order_12345",
});

console.log(payment.paymentLink);
// → https://easycryptopay.xyz/pay/inv_xK9mP2rT

Verifying webhook signatures

javascript
import { verifyWebhookSignature } from "@easycryptopay/node";

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyWebhookSignature({
    rawBody: req.body.toString("utf8"),
    signatureHeader: req.header("X-ECP-Signature") || "",
    secret: process.env.ECP_WEBHOOK_SECRET,
  });
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body.toString("utf8"));
  // ... handle event.event
  res.json({ ok: true });
});

Raw body required

Pass the raw request buffer, not a parsed object. If your framework auto-parses JSON, disable that middleware for the webhook route.

The simplest integration (Free & Pro)

You don't need this SDK to accept crypto. The most reliable integration — and the one that works on every plan — is a redirect: create the payment server-side, then send the customer to the paymentLink you get back. The hosted page handles coin/network selection, the QR code, the countdown, and confirmations for you.

javascript
// Server-side (any runtime). Works on every plan.
const res = await fetch("https://easycryptopay.xyz/api/crypto/deposits", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.ECP_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ amount: 99.99, metadata: { order_id: "order_12345" } }),
});
const { paymentLink } = await res.json();

// Send the customer to the hosted checkout. They choose coin + network there;
// your server gets a signed deposit.completed webhook when the payment lands.
return Response.redirect(paymentLink, 303);
// (or, client-side: window.location.href = paymentLink)

The iframe widget below is a Pro upgrade that renders that exact checkout inside your own page, so customers never leave your domain. Everything else — webhooks, idempotency, metadata, test mode — is identical between the two.

Available on every plan

The drop-in SDK below (embed.js + /embed/pay/<token>) works on Free and Pro alike — it used to be Pro-only and no longer is. The hosted /pay/<token> link remains available too; same checkout, rendered on easycryptopay.xyz rather than your domain. See what actually differs between plans →

Drop-in widget

Create the payment server-side with POST /api/crypto/deposits , then mount the iframe with the returned paymentToken:

code
<!-- Drop the SDK once, anywhere in your page. -->
<script src="https://easycryptopay.xyz/embed.js" defer></script>

<!-- Container the iframe will be injected into. -->
<div id="ecp-checkout"></div>

<script>
  // 1. Create the payment server-side with your API key. The response gives
  //    you a paymentToken — pass it to the SDK.
  // 2. Mount the iframe into your container. The SDK auto-resizes to fit
  //    the embedded page's content and relays lifecycle events.
  document.addEventListener("DOMContentLoaded", function () {
    EasyCryptoPay.checkout({
      token: "inv_xK9mP2rT",
      container: "#ecp-checkout",
      onPaid: function (event) {
        // event = { type: "ecp:paid", paymentToken, amountUsd, txId }
        window.location.href = "/thank-you?order=" + encodeURIComponent(event.paymentToken);
      },
      onExpired: function () {
        // 60-min window closed without payment. Offer to mint a fresh token.
      },
      onError: function (event) {
        console.error("[ECP]", event.message);
      },
    });
  });
</script>

React

javascript
// Same flow inside a React component. The SDK is browser-only — load it
// once via <script> in your <head> (or via next/script with strategy="afterInteractive").
import { useEffect, useRef } from "react";

export function CryptoCheckout({ token, onPaid }) {
    const containerRef = useRef(null);
    useEffect(() => {
        if (!containerRef.current) return;
        const handle = window.EasyCryptoPay.checkout({
            token,
            container: containerRef.current,
            onPaid,
        });
        return () => handle.destroy();
    }, [token, onPaid]);
    return <div ref={containerRef} />;
}

Lifecycle events

The SDK forwards postMessage events from the iframe to your callbacks. The paymentToken is included on every event so you can route by checkout if you embed multiple at once.

EasyCryptoPay.checkout(options)

FieldTypeDescription
token string paymentToken returned by POST /api/crypto/deposits. Required.
container HTMLElement | string Element or CSS selector the iframe will be appended into. Required.
onReady function Fired when the iframe has mounted. Payload: { paymentToken }.
onPaid function Fired when the deposit settles. Payload: { paymentToken, amountUsd, txId }.
onExpired function Fired when the 60-min window closes without payment.
onFailed function Fired when the exchange rejects the deposit.
onError function Fired when the checkout can't proceed. Payload: { paymentToken, message, code? }. code is \"embed_not_authorized\" when the embedding origin isn't on the merchant's allowlist, \"PLAN_LIMIT_REACHED\" when a Free merchant is over their volume cap, or absent for a generic create-deposit error. Wire this up so a blocked embed falls back to the hosted link instead of hanging.
onClose function Fired when destroy() is called on the returned handle.

Returns { iframe, token, destroy() }. Call destroy() to detach the listener and remove the iframe (e.g. when closing a modal).

Headless (render your own UI)

If you'd rather skip the iframe and render the address + QR yourself, the same public API powers the hosted page. This works on both Free and Pro — the Pro gate is only on the iframe SDK above, not on the JSON endpoints. Mint the payment server-side, then drive the customer flow from your own components:

javascript
// Prefer your own UI over the iframe? Build the checkout yourself. Your API
// key is server-side ONLY (never ship it to the browser); the customer-facing
// steps use the PUBLIC /api/pay/<token> endpoints.
//
//   1. (server) Create a payment with your API key.
//   2. Fetch currencies for the customer to choose from.
//   3. POST the customer's choice to mint the deposit.
//   4. Render the depositAddress + expectedCryptoAmount in your own UI.
//   5. Poll status (server-side /check) or rely on the webhook.
//
// CORS: /api/pay/* sends Access-Control-Allow-Origin: *, so the customer-facing
// steps (2 + 3) can run in the browser on your own domain. Your API key never
// leaves the server. (Step 5's /check is a Bearer endpoint — keep it server-side.)

const create = await fetch("https://easycryptopay.xyz/api/crypto/deposits", {
    method: "POST",
    headers: {
        "Authorization": `Bearer ${process.env.ECP_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": crypto.randomUUID(),
    },
    body: JSON.stringify({ amount: 99.99, metadata: { order_id: "order_12345" } }),
});
const { paymentToken } = await create.json();

// Then, from the customer's browser (CORS-enabled) or your backend:
//   POST /api/pay/<paymentToken>/create-deposit  with { currency, networkCode }
// → { depositAddress, depositTag, expectedCryptoAmount, expiresAt }.

Funds always settle to the merchant who issued the token

/embed/pay/<token> sets frame-ancestors * so any merchant origin can iframe it. A hostile site embedding the page can't redirect the payment — funds flow to whoever called POST /api/crypto/deposits with the API key. Phishing-style overlays are the merchant's own responsibility, the same as any third-party checkout.

Restrict who can embed (optional)

By default the embed page is loadable from any merchant origin. Open API & Webhooks → Embed allowlist in your dashboard and add one origin per line ( https://yourdomain.com , one per line). Once non-empty, /embed/pay/<token> will only render when the request's Referer origin matches a line in your allowlist; any other site sees an inline "Embed not authorized" notice instead of the checkout. Empty list = no allowlist, which is the default.

Programmatically, the same value lives on PUT /api/branding as allowedEmbedOrigins: string[] — max 25 entries, each must parse to a canonical http(s) origin (scheme + host + non-default port; no paths).

How pricing works

When auto-convert is enabled for a currency, the hosted /pay page quotes the customer 1.01× the normal crypto amount. Once the deposit confirms, we place a limit-sell at market × 0.99 on the source exchange's <COIN>USDT pair. Best case the limit fills and you net ~100% in USDT — the 1%/1% buffer absorbs slippage. Limit unfilled after 5 minutes? We cancel and fall back to a market sell. Both legs are executed on-exchange, no third-party DEX or aggregator involved.

Toggle per currency

HTTP method PUT /api/crypto/auto-convert

Cookie session only — this is a management endpoint and rejects API-key auth with 403. A valid session is sufficient; there is no second challenge.

bash
# Toggle auto-convert. Available on every plan.
# Browser session only — this is a management endpoint (not API-key accessible).
curl -X PUT https://easycryptopay.xyz/api/crypto/auto-convert \
  -H "Content-Type: application/json" \
  -H "Origin: https://easycryptopay.xyz" \
  --cookie "ecp_session=…" \
  -d '{ "all": true, "enabled": true }'

Successful response

json
{
  "currency": "BTC",
  "all": false,
  "enabled": true,
  "autoConvertCurrencies": { "BTC": true }
}

Error: trading permission missing

Before enabling, we probe the exchange's spot-trading permission. If the API key isn't allowed to trade, the request fails with a structured error your UI can map to a click-path ("Account → API → Enable Spot Trading") per exchange:

json
// 400 Bad Request
{
  "error": "Trading permission missing on the connected exchange.",
  "code": "TRADING_PERMISSION_REQUIRED",
  "exchangeName": "MEXC",
  "help": {
    "permission": "Spot Trading",
    "path": "Account → API Management → Edit Key → Enable Spot Trading",
    "notes": "Save, then click \"Test connection\" again."
  }
}

Lifecycle field

Every Deposit row carries an autoConvert sub-object with status:

  • null — auto-convert wasn't enabled, or the deposit hasn't terminated yet.
  • pending — limit-sell placed; waiting for fill.
  • filled — terminal. USDT received; receivedUsdt populated.
  • failed — terminal. Both limit and market fallback failed; we email the merchant (throttled to 1 per exchange per 24h).
  • skipped — terminal. Order not placed (below exchange minimum notional, no trading client, etc.).

deposit.auto_converted webhook

Subscribe to this event if you only care about the USDT-equivalent amount. deposit.completed fires earlier (when funds confirm on-chain) and won't have the conversion outcome.

json
{
  "apiVersion": "1",
  "event": "deposit.auto_converted",
  "timestamp": 1745311520,
  "data": {
    "id": "dep_abc123",
    "paymentToken": "inv_xK9mP2rT",
    "currency": "BTC",
    "receivedCryptoAmount": 0.00125,
    "receivedAmountUsd": 99.85,
    "status": "completed",
    "autoConvert": {
      "enabled": true,
      "status": "filled",
      "orderId": "1234567",
      "symbol": "BTCUSDT",
      "receivedUsdt": 99.87
    },
    "metadata": { "order_id": "order_12345" }
  }
}

Manual retry

HTTP method POST /api/payments/[id]/auto-convert/retry

Owner-only. Re-runs the pipeline for a deposit whose previous attempt was failed or skipped. Returns 409 if a retry is already in-flight (status = pending).

Supported exchanges

36 exchanges currently support trading-API auto-convert: Binance, MEXC, Bybit, Kraken, KuCoin, OKX, Bitget, Coinbase, Gate.io, BingX, LBank, Crypto.com, HTX, Bitfinex, Gemini, CoinEx, XT, Toobit, CoinW, WEEX, Bithumb, Bitstamp, Bitpanda, Tokocrypto, Bitvavo, WhiteBIT, VALR, Bitso, CoinDCX, BTCTurk, Upbit, WazirX, Coins.ph, Independent Reserve, Quidax, BitMart. Per-exchange permission help (name + click-path) is surfaced in the help field of the TRADING_PERMISSION_REQUIRED error above.

Issuing API keys

Sign in, head to Settings → API & Webhooks, and click Create key. Two flavours:

  • ecp_live_… — production keys. Owner-only to create or revoke.
  • ecp_test_… — sandbox keys. Same shape; deposits and invoices are flagged isTest: true and excluded from balance, charts, customer rollups, and stats.

Shown once

We hash the key server-side; the full value is returned exactly once on creation. Lost it? Delete and re-issue — there's no recovery path.

Where Bearer keys work

Programmatic access is scoped to the read/write payment surface:

  • GET /api/crypto/currencies
  • POST /api/crypto/deposits · GET /api/crypto/deposits/:id/check · POST /api/crypto/deposits/:id/test-complete
  • GET /api/invoices · POST /api/invoices · GET /api/invoices/:id
  • GET /api/payments · GET /api/payments/:id
  • GET /api/customers
  • GET /api/balance

Management routes (branding, team, billing, exchange config, plans, site config) reject Bearer auth with 403. Use the dashboard.

CSRF

State-changing cookie-authenticated requests must send an Origin header matching https://easycryptopay.xyz. Bearer-authenticated requests skip the check (browsers don't send Authorization cross-origin without explicit CORS, so CSRF isn't reachable). Public endpoints under /api/pay and the OAuth callback are exempt.

Error response shape

Every error returns JSON in the form { "error": "<message>" } . Routes that want fine-grained client handling add a code field.

HTTP status codes

Status codes you'll see

FieldTypeDescription
400 Bad Request Validation failed or business rule violated (e.g. extending a plan that isn't active).
401 Unauthorized Session or API key missing, expired, or revoked.
403 Forbidden Authenticated but missing role/permission, CSRF header missing, or API key sent to a cookie-only route.
404 Not Found Resource doesn't exist or belongs to another business.
409 Conflict Idempotency-Key reused with a different body (code: IDEMPOTENCY_CONFLICT), the same key still being processed (code: IDEMPOTENCY_IN_PROGRESS), or an auto-convert retry already in-flight.
410 Gone Recovery window expired (e.g. soft-deleted business past 30 days).
429 Too Many Requests Rate limit hit. Emitted by nginx, not Node — bodyless plain-text response.
500 Internal Server Error Bug. Retry-safe operations idempotent on Idempotency-Key.
503 Service Unavailable /api/health returns this when the database probe fails.

Named error codes

Branch on code (not message strings).

IDEMPOTENCY_CONFLICT

Same Idempotency-Key replayed within 24 hours but the body differs. Pick a fresh key.

json
// 409 Conflict
{
  "error": "Idempotency-Key was previously used with a different request body",
  "code": "IDEMPOTENCY_CONFLICT"
}

IDEMPOTENCY_IN_PROGRESS

The same Idempotency-Key is still being processed by an earlier request (the slot is reserved before any side effect runs). Wait a moment and retry the identical request — you'll get the original response once it settles.

json
// 409 Conflict
{
  "error": "A request with this Idempotency-Key is currently in progress",
  "code": "IDEMPOTENCY_IN_PROGRESS"
}

TRADING_PERMISSION_REQUIRED

Returned by PUT /api/crypto/auto-convert when the connected exchange's API key lacks spot-trading permission. Response carries help (per-exchange click-path) and exchangeName. See the Auto-convert section for an example.

Other codes you may branch on

The full registry lives in constants/error-codes.ts (and the OpenAPI spec). The most common caller-actionable ones:

CodeHTTPMeaning
VALIDATION_FAILED400Body failed validation; details[] carries the field paths + messages.
BAD_REQUEST400Malformed JSON or a missing required field/header.
UNAUTHORIZED401Missing/invalid key or session. Sign in or check the Authorization header.
API_KEY_NOT_ALLOWED403A Bearer key hit a cookie-only (management) endpoint.
PLAN_LIMIT_REACHED402The Free plan's rolling-window volume cap is reached; payload includes usage, cap, and resetsAt. Existing payments are unaffected.
NOT_FOUND404The id doesn't exist or belongs to another business / mode.
NO_ACTIVE_BUSINESS404/400The session has no active business selected (a setup state).
NO_DEPOSIT_YET409test-complete called before a coin+network was picked. Select a currency first.
PAYOUTS_DISABLED403Payouts aren't enabled for this business (or the key lacks the payouts scope).
PAYOUT_LIMIT_EXCEEDED409The rolling 24h payout ceiling would be exceeded.
INVALID_ADDRESS400Withdrawal destination failed per-network format validation.
GONE410A soft-deleted business past its 30-day recovery window.

Some older routes still emit { "error": "…" } with no code — that shape stays valid for back-compat, so fall back to the HTTP status when code is absent.

Rate limits

Rate limits are enforced at the openresty (nginx) layer with these burst thresholds:

Burst limits per source IP

FieldTypeDescription
/api/auth/* burst=5 Sign-in, sign-up, email-code requests and 2FA verification — strict to slow brute-force. (The password-reset routes it used to name answer 410.)
/api/pay/* burst=20 Public payment pages. Higher because customers can retry quote / currency selection.
/api/* burst=10 Everything else (deposits, invoices, payments, etc.).

Limits are per-IP. When breached, nginx returns a bodyless 429. Production traffic should never get close — design your retry to back off on 429 and 5xx.

bash
# All list endpoints follow the same convention.
curl "https://easycryptopay.xyz/api/payments?limit=50&offset=100" \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"

# Response shape:
# { "payments": [...], "total": 312, "stats": {...} }

Query params

FieldTypeDescription
limit number Page size. Default 50, maximum 200. Larger values are clamped server-side.
offset number Zero-based offset into the result set. Default 0.

Response shape: { <items>, total, limit, offset, stats } where <items> is the resource-named array (e.g. payments, invoices), total is the count of rows matching your filters, and stats is endpoint-specific aggregate data. On /api/customers the unfiltered business-wide count lives in stats.total. (Withdrawals cap limit at 100; the others at 200.)

Field naming (snake_case & camelCase)

Newer endpoints ( /api/crypto/deposits , /api/balance ) return camelCase. The older merchant-facing endpoints ( /api/invoices , /api/payments , /api/customers ) historically returned snake_case and now dual-emit both shapes so existing integrations keep working. Every row carries the snake_case key ( payment_token , created_at , amount_usd …) AND its camelCase alias ( paymentToken , createdAt , amountUsd …) with the same value.

snake_case is deprecated

New code should branch on the camelCase fields. The snake_case keys are kept for backwards compatibility and will be removed in a future major version. Request bodies on POST /api/invoices also accept either shape (snake wins when both are present).

My checkout says "No payment methods available"

For live payments the currency picker is built from your wallet addresses, so with no exchange connected there are none to show. Connect an exchange under Dashboard → Setup and make sure at least one deposit address exists for the coins you want to accept (required on both Free and Pro). Your POST /api/crypto/deposits calls still succeed and return a link; the live link just has nothing to offer until a wallet exists. Test mode is differentecp_test_ payments serve a synthetic catalog and mint TEST_ addresses, so the picker works with no exchange at all.

My checkout returns 402 PLAN_LIMIT_REACHED

The business is on the Free plan and has reached its settled-volume cap for the current rolling window. Creating new invoices, deposits, or checkouts is refused until either older payments age out of the window (see limit.resetsAt in the response) or the account upgrades to Pro, which has no cap. Payments that already exist are not affected — they confirm and credit normally. Test-mode keys (ecp_test_) are exempt, so you can keep integrating against the sandbox either way.

My webhook isn't firing

  • Check the URL in Settings → Branding isn't pointing at a private/loopback IP — we refuse those at save time (SSRF guard).
  • Open Settings → Branding → Recent deliveries — every attempt is recorded, with status, error, and the next retry time. Failed deliveries can be replayed manually from there.
  • Click Test delivery — fires a synthetic webhook.test event without creating a deposit. Confirms wiring end-to-end.

Signature verification keeps failing

Use the raw body

HMAC is computed over `${t}.${rawBody}`. If your framework parses JSON before you get a chance to verify, the signature won't match. Disable JSON parsing on the webhook route and read the raw bytes — see the SDK example above.

My test deposit doesn't show in the dashboard

Sandbox rows (created with ecp_test_* keys) are filtered out of every aggregate the merchant dashboard shows: balance, payments list, invoices list, customer last-5, charts, and stats. That's by design — sandbox traffic must never touch live accounting. Test deposits still fire webhooks (with isTest: true in the payload) so you can integrate against them.

Auto-convert returns TRADING_PERMISSION_REQUIRED

Your exchange API key has deposit-read permission but not spot-trading. The error response carries a help object with the exact menu path on your exchange. Enable trading, save, then retry the toggle.

Idempotency-Key conflicts

A 409 IDEMPOTENCY_CONFLICT means you reused a key within 24h with a different body. Either: replay with the original body (you'll get the cached response), or pick a new key (typically your own order id, an order-revision counter, or a UUID per attempt).

2026-08-25

  • ⚠ `paymentLink` now returns the short public link (https://easycryptopay.xyz/xK9m) everywhere it appears — API responses and webhook payloads alike. The old https://easycryptopay.xyz/pay/<token> form keeps resolving forever, so existing links in invoices, emails and chats are unaffected; but if you string-match on “/pay/”, match on the unchanged `paymentToken` field instead.
  • Payment pages no longer hide a coin when your PRIMARY exchange cannot price it. If the coin’s wallets live on another exchange you have connected, the price is taken from there — and the checkout can complete with it, which it previously could not.
  • MATIC is quotable on exchanges that list it as POL. The alias was already handled on the coin picker and is now handled when the deposit is created.

2026-06-02

  • Docs accuracy pass: corrected the deposit-status, currencies, auto-convert, timeseries, and invoice examples to match the live responses.
  • Added an explicit “Select a coin + network” quickstart step (POST /api/pay/:token/create-deposit) so the test loop completes end-to-end.
  • Node SDK 0.3.0 — typed list responses, a discriminated WebhookEvent union + constructEvent(), withdrawals + retryAutoConvert + selectCurrency methods.
  • OpenAPI 3.1 spec now covers the payouts (withdrawals) endpoints and the webhook apiVersion field.
  • GET /api/payments and /api/invoices now echo limit + offset; data-plane reads return machine-readable error codes.
  • New ⌘K search across the docs.

Earlier

  • Public OpenAPI spec at /openapi.json; CORS enabled on /api/pay/* for headless checkouts.
  • GET /api/invoices/:id and /api/payments/:id accept a payment token or an id.
  • snake_case + camelCase dual-emit across the legacy list endpoints (camelCase canonical).

Versioning & deprecations

  • Webhooks carry an apiVersion field (currently "1"). Additive changes (new fields inside data) do not bump it — ignore unknown keys. Breaking changes bump the major and ship alongside v1 for a deprecation window.
  • OpenAPI is versioned in its info.version at /openapi.json; the Node SDK follows semver.
  • Field naming. The legacy list endpoints emit both snake_case and camelCase keys. camelCase is canonical; the snake_case aliases are deprecated and will be removed in a future major version — branch on the camelCase fields.
  • Deploys & status./api/version reports the running build + commit; /api/health reports a live DB probe.