EasyCryptoPayDocs
Sign inSign up free
No key set
Get a key →
Get started
  • How it works
  • Key concepts
  • Quick start
  • 1. Create a payment
  • 2. Select a coin
  • 3. Complete in test
  • 4. Handle the webhook
  • Next steps
API reference
  • Overview
  • Endpoint index
  • Create a deposit
  • Get deposit status
  • Complete a test deposit
  • Create an invoice
  • List payments
  • Get balance
  • List currencies
Webhooks
  • Overview
  • Setting up
  • Events
  • Payload shape
  • Signature header
  • Verify signatures
  • Retries & replay
  • Test deliveries
SDKs
  • Overview
  • Install
  • Creating a deposit
  • Verify webhook signatures
  • Related
Embed
  • Overview
  • Drop-in widget
  • React
  • Lifecycle events
  • Headless
  • Restrict origins
Auto-convert (Pro)
  • Overview
  • How pricing works
  • Toggle per currency
  • Lifecycle field
  • Webhook event
  • Manual retry
  • Supported exchanges
Mobile app
  • Overview
  • Bearer tokens
  • Sign-up
  • Sign-in
  • Sessions
  • Step-up (sudo)
  • Push notifications
Authentication
  • Overview
  • Issuing API keys
  • Where Bearer keys work
  • CSRF
Errors & limits
  • Overview
  • Error response shape
  • HTTP status codes
  • Named error codes
  • Rate limits
Conventions
  • Pagination
  • Field naming (snake/camel)
Troubleshooting
  • Overview
  • Webhook not firing
  • Signature failing
  • Test deposit missing
  • Trading permission
  • Idempotency conflicts
Reference
  • Changelog
  • Versioning & deprecations
Get started
  • How it works
  • Key concepts
  • Quick start
  • 1. Create a payment
  • 2. Select a coin
  • 3. Complete in test
  • 4. Handle the webhook
  • Next steps
API reference
  • Overview
  • Endpoint index
  • Create a deposit
  • Get deposit status
  • Complete a test deposit
  • Create an invoice
  • List payments
  • Get balance
  • List currencies
Webhooks
  • Overview
  • Setting up
  • Events
  • Payload shape
  • Signature header
  • Verify signatures
  • Retries & replay
  • Test deliveries
SDKs
  • Overview
  • Install
  • Creating a deposit
  • Verify webhook signatures
  • Related
Embed
  • Overview
  • Drop-in widget
  • React
  • Lifecycle events
  • Headless
  • Restrict origins
Auto-convert (Pro)
  • Overview
  • How pricing works
  • Toggle per currency
  • Lifecycle field
  • Webhook event
  • Manual retry
  • Supported exchanges
Mobile app
  • Overview
  • Bearer tokens
  • Sign-up
  • Sign-in
  • Sessions
  • Step-up (sudo)
  • Push notifications
Authentication
  • Overview
  • Issuing API keys
  • Where Bearer keys work
  • CSRF
Errors & limits
  • Overview
  • Error response shape
  • HTTP status codes
  • Named error codes
  • Rate limits
Conventions
  • Pagination
  • Field naming (snake/camel)
Troubleshooting
  • Overview
  • Webhook not firing
  • Signature failing
  • Test deposit missing
  • Trading permission
  • Idempotency conflicts
Reference
  • Changelog
  • Versioning & deprecations

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 live on this page
Sign up free (no credit card), grab a test key prefixed ecp_test_ from the API & Webhooks page, and paste it into the bar above. Every curl snippet below gets a Run button — responses appear inline and chain into the next example (e.g. the deposit id flows into /check and /test-complete). Nothing touches real crypto.

How it works

  1. 1You call

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

  2. 2Customer pays

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

  3. 3We confirm

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

  4. 4We 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.
Get started
Quick start

Your first payment, end to end, in test mode. Four steps — no exchange required.

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.

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" }
  }'
Paste a test API key in the bar above to enable Run.

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/pay/inv_xK9mP2rT"
}

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

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" }'
Paste a test API key in the bar above to enable Run.

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.confirming → deposit.completed webhook sequence. (A paymentToken also works once a deposit exists; calling it before step 2 returns 409 NO_DEPOSIT_YET.)

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"
Paste a test API key in the bar above to enable Run.

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. 0.1% platform fee applies on the Free plan; Pro users pay 0%.

Next steps

  • Free vs Pro — what each plan unlocks at the API level.
  • 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.
Plans
Free vs Pro

The hosted checkout, webhooks, idempotency, test mode, exchange connections, and every public REST endpoint work identically on both plans. Pro removes the 0.1% platform fee and unlocks the in-page embedded checkout plus a handful of branding controls below.

FeatureFreePro
Platform fee per payment0.1%0%
Hosted /pay checkoutIncludedIncluded
REST API, webhooks, test mode, all ~48 exchangesIncludedIncluded
Embedded checkout (embed.js iframe)—Included
Embed origin allowlist—Included
Hide "Powered by EasyCryptoPay"—Included
Custom CSS on the checkout page—Included
Auto-convert non-stables → USDT—Included
Active API keys1 live + 1 testUp to 10
Idempotency-Key + metadataIncludedIncluded
How the gate works in practice
Pro-only endpoints and fields return 403 { "code": "PRO_REQUIRED", "feature": "<name>" } when called on a Free plan. The dashboard surfaces an upgrade prompt; SDK consumers can branch on code === "PRO_REQUIRED" to do the same. Plan changes take effect immediately — there's no propagation delay between toggling Pro on/off and the gates flipping.

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.

API reference
REST API

JSON in, JSON out. Base URL https://easycryptopay.xyz. Auth via Bearer API key.

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.

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" }
  }'
Paste a test API key in the bar above to enable Run.

Body parameters

FieldTypeDescription
amount
required
numberAmount in USD. Converted to the crypto amount at the current market rate once the customer selects a coin on the hosted payment page.
metadataobjectFree-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-KeystringOptional. 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/pay/inv_xK9mP2rT"
}

Response fields

FieldTypeDescription
invoiceIdstringServer-side identifier for the payment.
paymentTokenstringPublic token used in the hosted /pay URL. Works with the /check endpoint.
expectedAmountUSDnumberUSD 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.
expiresAtstring (ISO 8601)Payment link expiry — 7 days from creation. Once the customer picks a currency the resulting deposit gets a tighter 60-minute window.
statusstringInitially 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.
feePercentnumberPlatform fee rate that will apply on completion (0 for Pro plan, 0.1 for Free plan).
feeUsdnumberPlatform fee in USD that will be deducted on completion.
isTrialbooleanDeprecated — always false. Retained for backward compatibility.
isTestbooleanTrue if created with a test-mode API key.
metadataobject | nullThe metadata you provided, or null.
paymentLinkstringHosted 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.

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

Path parameters

FieldTypeDescription
id
required
stringThe 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"
Paste a test API key in the bar above to enable Run.

Response

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

Response fields

FieldTypeDescription
statusstringOne of pending, awaiting_confirmation, completed, expired, or failed.
confirmationsnumberCurrent 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).
txIdstring | nullBlockchain transaction id once detected.
amountUSDnumberUSD amount detected on-chain.
totalCreditedUSDnumberUSD 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.

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
id
required
stringdepositId 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"
Paste a test API key in the bar above to enable Run.

Response

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

Response fields

FieldTypeDescription
statusstringAlways completed on success.
txIdstringSynthetic transaction id prefixed with test_tx_.
confirmationsnumberEchoes confirmationsRequired so chained polling logic settles immediately.
amountUSDnumberThe original expectedAmountUSD from create-deposit.
totalCreditedUSDnumberAmount credited to your test balance after simulated fees.
isTestbooleanAlways 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.

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" }
  }'
Paste a test API key in the bar above to enable Run.

Body parameters

FieldTypeDescription
title
required
stringShown at the top of the checkout page.
amount_usd
required
numberInvoice total in USD.
description
required
stringLong-form description shown on the invoice page.
recipient_emailstringCustomer's email. Required if send is true.
sendbooleanIf true, we email the customer a checkout link immediately.
expires_in_daysnumberDays from now until the invoice expires. 1–365, or 0 for no expiry. Default 7. Ignored if expires_at is also provided.
expires_atstring (ISO 8601)Specific expiration timestamp. Must be in the future and at most 1 year out. Takes precedence over expires_in_days.
metadataobjectFree-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/pay/inv_bN3qW7sY",
    "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
idstringInvoice identifier.
titlestringEchoed from the request.
payment_linkstringShare this URL with your customer.
statusstringOne of draft, sent, paid, or expired.
is_testbooleanTrue if created with a test-mode API key.
metadataobject | nullYour 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.

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.

GET/api/payments

Query parameters

FieldTypeDescription
statusstringFilter by deposit status (pending, awaiting_confirmation, completed, expired, failed).
currencystringFilter by currency code — e.g., USDT.
limitnumber
default: 50
Max results per page. Range: 1–200.
offsetnumber
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"
Paste a test API key in the bar above to enable Run.

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
paymentsPayment[]Matching deposits for this page. Field names are snake_case.
payments[].idstringDeposit identifier.
payments[].statusstringpending, awaiting_confirmation, completed, expired, or failed.
payments[].currencystringCoin ticker (e.g. USDT).
payments[].networkstringBlockchain network code (e.g. TRC20).
payments[].networkDisplayNamestringHuman-readable network name.
payments[].amountnumberCrypto amount actually received (0 until confirmed).
payments[].amount_usdnumberUSD value of what was received.
payments[].expected_amount_usdnumberUSD amount the merchant requested.
payments[].expected_crypto_amountnumberCrypto amount the customer was asked to send.
payments[].deposit_addressstringOn-chain destination address.
payments[].deposit_tagstring | nullMemo/tag for chains that need it (XRP, etc.).
payments[].tx_idstring | nullBlockchain transaction id once detected.
payments[].confirmationsnumberCurrent confirmation count.
payments[].required_confirmationsnumberConfirmations required for completion.
payments[].fee_usdnumberPlatform fee in USD.
payments[].fee_percentnumberFee rate applied (0 for Pro plan, 0.1 for Free plan).
payments[].total_amount_usdnumber | nullNet credited (amount_usd − fee_usd). Null until completed.
payments[].is_trialbooleanDeprecated — always false. Retained for backward compatibility.
payments[].is_testbooleanTrue if created with a test API key.
payments[].metadataobject | nullFree-form JSON you supplied at create time.
payments[].payment_linkstringHosted /pay URL for this deposit.
payments[].created_atstring (ISO 8601)Creation timestamp.
payments[].completed_atstring (ISO 8601) | nullCompletion timestamp, null until completed.
payments[].expires_atstring (ISO 8601)Deposit expiry timestamp.
totalnumberTotal matching deposits across all pages.
stats.totalReceivednumberSum of receivedAmountUsd across matching completed deposits.
stats.totalFeesnumberSum of fee_usd across matching completed deposits.
stats.completedCountnumberNumber of matching deposits with status completed.
stats.pendingCountnumberNumber 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.

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.

GET/api/balance

Request

bash
curl https://easycryptopay.xyz/api/balance \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"
Paste a test API key in the bar above to enable Run.

Response

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

Response fields

FieldTypeDescription
balancenumberCurrent settled balance in USD.
totalVolumeUsdnumberLifetime completed deposit volume.
totalReceivednumberSum of all USD amounts received (before fees).
totalFeesnumberTotal platform fees paid to date.
pendingCountnumberDeposits currently in pending or confirming state.
currencystringAlways 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.

GET/api/customers

Query parameters

FieldTypeDescription
statusstringFilter by activity: "active" (paid within 30 days) or "inactive".
searchstringCase-insensitive match on email or name.
limitnumber
default: 50
Max results per page. Range 1–200.
offsetnumber
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"
Paste a test API key in the bar above to enable Run.

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
customersCustomer[]Matching customers for this page (snake_case keys carry camelCase aliases).
customers[].totalPaidUsdnumberLifetime net USD this customer has paid (live deposits only).
customers[].transactionCountnumberDeposits + invoices in the caller's test/live window.
customers[].statusstring"active" if they paid within the last 30 days, else "inactive".
customers[].transactionsTransaction[]Up to 5 most recent, newest first.
totalnumberTotal customers matching the filter across all pages.
stats.totalRevenuenumberSum 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.

GET/api/crypto/currencies

Request

bash
curl https://easycryptopay.xyz/api/crypto/currencies \
  -H "Authorization: Bearer ecp_live_YOUR_KEY"
Paste a test API key in the bar above to enable Run.

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
currenciesCurrency[]Array of supported currencies (USDT, USDC).
currencies[].coinstringTicker — USDT or USDC.
currencies[].namestringHuman-readable name.
currencies[].iconstringPath to the coin icon asset.
currencies[].isStablecoinbooleanAlways true for the currently supported coins.
currencies[].sortOrdernumberDisplay order index used by the picker UI.
currencies[].pricePerUnitnumberCurrent USD price per 1 unit of the currency.
currencies[].networksNetwork[]Networks supported by your connected exchange.
currencies[].networks[].codestringNetwork code (e.g. TRC20, ERC20). Used internally by the hosted /pay page when the customer picks a network.
currencies[].networks[].displayNamestringHuman-readable network name.
currencies[].networks[].minConfirmnumberConfirmations required before a deposit is complete.
currencies[].networks[].estimatedTimestringRough on-chain settlement time, e.g. "~1 min".
currencies[].networks[].walletAddressstringAddress of the default wallet (legacy alias for wallets[0].address).
currencies[].networks[].walletTagstring | nullMemo/tag for chains that need it (legacy alias for wallets[0].tag).
currencies[].networks[].isDefaultbooleanTrue for the network used as default when none is selected.
currencies[].networks[].walletGeneratedbooleanTrue when at least one wallet exists for this network.
currencies[].networks[].walletCountnumberNumber of wallets configured for this network.
currencies[].networks[].walletsWallet[]All wallets configured for this network, primary first.
currencies[].networks[].wallets[].idstringWallet identifier.
currencies[].networks[].wallets[].addressstringOn-chain address.
currencies[].networks[].wallets[].tagstring | nullMemo/tag for the wallet.
currencies[].networks[].wallets[].isDefaultbooleanTrue for the wallet used by default.
currencies[].networks[].wallets[].createdAtstring (ISO 8601)When the wallet was created.
currencies[].networks[].wallets[].exchangeConfigIdstring | nullExchange config that owns the wallet (null for legacy entries).
currencies[].networks[].wallets[].exchangeKeystring | nullLowercase exchange key (e.g. mexc, binance).
currencies[].networks[].wallets[].exchangeNamestringDisplay name of the source exchange.
exchangeConnectedbooleanFalse if your business has not yet connected an exchange API key. In that case networks will be empty.
exchangestring | undefinedDisplay name of the connected exchange. Omitted when exchangeConnected is false.
Webhooks
Webhooks

Register a URL, get signed events pushed when a payment changes state. HMAC-SHA256, 6 retries over ~7 hours, full replay log.

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/pay/pay_xK9mP2rT",
    "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']);
}

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

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

SDKs
Node.js SDK

@easycryptopay/node — typed client + signature verifier. Node 18+ or any fetch-capable runtime.

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.

Related

  • Quick start — end-to-end example.
  • API reference — what each SDK method wraps.
Embed · Pro
Embed checkout in your site

Drop a single <script> on your page and the customer pays inline — no redirect to an external checkout. The SDK iframes /embed/pay/<token>, auto-resizes to fit, and relays paid / expired / error events back to your callbacks.

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.

Pro plan required for the iframe widget
The drop-in SDK below (embed.js + /embed/pay/<token>) is a Pro feature. Free merchants on the hosted /pay/<token> link instead — same checkout, just rendered on easycryptopay.xyz rather than your domain. Calls to /embed/pay/… on a Free plan render an upgrade notice inside the iframe and fire an onError event (code: "pro_required"), so your integration can fall back to the hosted link instead of failing silently. See the full Free vs Pro split →

Drop-in widgetPRO

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

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

ReactPRO

jsx
// 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 eventsPRO

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
tokenstringpaymentToken returned by POST /api/crypto/deposits. Required.
containerHTMLElement | stringElement or CSS selector the iframe will be appended into. Required.
onReadyfunctionFired when the iframe has mounted. Payload: { paymentToken }.
onPaidfunctionFired when the deposit settles. Payload: { paymentToken, amountUsd, txId }.
onExpiredfunctionFired when the 60-min window closes without payment.
onFailedfunctionFired when the exchange rejects the deposit.
onErrorfunctionFired when the checkout can't proceed. Payload: { paymentToken, message, code? }. code is "pro_required" when the merchant isn't on Pro, "embed_not_authorized" when the embedding origin isn't on their allowlist, or absent for a generic create-deposit error. Wire this up so a Free-plan or blocked embed falls back to the hosted link instead of hanging.
onClosefunctionFired 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)PRO

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

Pro feature
Auto-convert non-stable deposits to USDT

Pro merchants can flip non-stable deposits (BTC, ETH, SOL, XRP, BNB, TRX, LTC, DOGE, and more — every supported coin except USDT) to USDT on the source exchange the moment they confirm on-chain. Customer pricing absorbs the round-trip slippage so merchants net ~100% in USDT.

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

PUT/api/crypto/auto-convert

Step-up authentication required. Cookie session only (this is a management endpoint and rejects API-key auth with 403).

bash
# Toggle auto-convert per non-stable currency. Pro plan + step-up required.
# 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=…; ecp_sudo=…" \
  -d '{ "currency": "BTC", "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

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.

Mobile
Native mobile app

A Flutter app for iOS and Android (download at /download) lets merchants run the full dashboard on the phone — same data, same plan gating, same auto-convert. Below is the auth surface third-party clients would use.

Bearer tokens

Mobile clients authenticate with an opaque ecp_m_… bearer token issued at sign-in. The token is SHA-256-hashed server-side; the raw value only lives in the device's secure storage (Keychain / Keystore). TTL is 90 days and slides on each authenticated request.

Mobile bearers unlock the same endpoint surface as a browser session — the full merchant dashboard — not the narrower API-key scope. Send the token as Authorization: Bearer ecp_m_….

Sign-up

POST/api/auth/mobile/sign-up

Mirrors the web sign-up but returns the bearer in the JSON body (no cookie) and accepts device + push metadata in the same call. The default Business is created in the same transaction.

Request body

FieldTypeDescription
emailstringRequired. Account email.
passwordstringRequired. Plain-text password (server hashes with bcrypt).
namestringOptional display name.
deviceobjectOptional. { deviceId, deviceName, platform: 'android'|'ios', appVersion, osVersion } — used for the sessions list and the per-device session-revoke flow.
pushTokenstringOptional FCM token; pushProvider defaults to 'fcm'.

Returns { token, expiresAt, ttlSeconds, user, business }. Errors: 400 invalid email/password, 409 duplicate email.

Sign-in

POST/api/auth/mobile/sign-in

Same body shape as sign-up minus name. When the account has 2FA enabled, the response is { requires2fa: true, method: "TOTP" | "EMAIL", challengeToken, expiresInSeconds, email } and the client posts the code to /api/auth/mobile/2fa/verify (with the challenge token) to receive the bearer.

For email-OTP 2FA, the same endpoint sends the code to the account email; resend is POST /api/auth/mobile/2fa/email/resend (throttled 3/5min/IP, 4/30min/user).

Session management

  • POST /api/auth/mobile/sign-out — revoke the current bearer (idempotent).
  • GET /api/auth/mobile/sessions — list this user's active mobile sessions; current row is flagged isCurrent.
  • DELETE /api/auth/mobile/sessions — revoke every session except the current one ("sign out everywhere else").
  • DELETE /api/auth/mobile/sessions/[id] — revoke a specific session.
  • POST /api/auth/mobile/switch-business — change the active business for this device. Bearer does not rotate (cookie sessions re-mint the JWT on switch; mobile bearers are opaque, so the row is the source of truth).
  • PUT /api/auth/mobile/device — update device metadata or push token. Pass pushToken: null to unregister from push.

Step-up (sudo) on mobile

The same gates that prompt for password re-verification on the web (live API-key creation, exchange config edits, etc.) apply on mobile. Difference: the sudo token comes back in the JSON body and the client sends it on the next sensitive call as X-Sudo-Token: <raw>.

  • GET /api/auth/mobile/step-up — current grace-window status.
  • POST /api/auth/mobile/step-up — verify password (+ 2FA code) → returns sudoToken, valid for 10 minutes.
  • DELETE /api/auth/mobile/step-up — end the grace window.

Push notifications

Lifecycle events fire Firebase Cloud Messaging v1 pushes to all of a business's registered devices. Currently: deposit.completed and invoice.paid (the friendlier framing wins when the deposit settles an invoice). Register the FCM token at sign-in / sign-up, or update it later via PUT /api/auth/mobile/device.

Authentication
API keys, sessions, and CSRF

Two ways to call the API: a browser session cookie (the merchant dashboard) or a Bearer API key (programmatic access). Choose the right one and you get the right CSRF behaviour for free.

Issuing API keys

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

  • ecp_live_… — production keys. Creating one requires a recent high-tier step-up (the challenge must be under 2 minutes old) so a leaked sudo cookie can't silently mint access; revoking one uses a normal step-up (5-minute window).
  • 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.

Reference
Errors, status codes, and rate limits

One uniform error shape, a small set of status codes, and named codes for the cases where the client needs to do something specific.

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
400Bad RequestValidation failed or business rule violated (e.g. extending a Free plan).
401UnauthorizedSession missing/expired, or sensitive route called outside the step-up grace window (code: STEPUP_REQUIRED).
403ForbiddenAuthenticated but missing role/permission, CSRF header missing, or API key sent to a cookie-only route.
404Not FoundResource doesn't exist or belongs to another business.
409ConflictIdempotency-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.
410GoneRecovery window expired (e.g. soft-deleted business past 30 days).
429Too Many RequestsRate limit hit. Emitted by nginx, not Node — bodyless plain-text response.
500Internal Server ErrorBug. Retry-safe operations idempotent on Idempotency-Key.
503Service Unavailable/api/health returns this when the database probe fails.

Named error codes

Branch on code (not message strings).

STEPUP_REQUIRED

Sensitive mutations (delete a business, regenerate a webhook secret, change exchange credentials, create a live API key, change email) need a recent password re-check. POST to /api/auth/step-up with {password, code?} (code is required when 2FA is on), then retry the original request.

json
// 401 Unauthorized
{
  "error": "Re-verify your identity to continue.",
  "code": "STEPUP_REQUIRED",
  "message": "Confirm your password (and 2FA code) to continue."
}

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.
PRO_REQUIRED403A Pro-only feature on a Free plan; payload includes a feature discriminator.
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=5Sign-in, sign-up, password reset, 2FA verification — strict to slow brute-force.
/api/pay/*burst=20Public payment pages. Higher because customers can retry quote / currency selection.
/api/*burst=10Everything 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.

Conventions
Pagination

All list endpoints (payments, invoices, customers, deliveries) take limit + offset query params and return total alongside the page.

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
limitnumberPage size. Default 50, maximum 200. Larger values are clamped server-side.
offsetnumberZero-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).
When things break
Troubleshooting

The handful of issues we get asked about most often. If you don't find yours here, contact support from the dashboard — every ticket gets a human reply.

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 different — ecp_test_ payments serve a synthetic catalog and mint TEST_ addresses, so the picker works with no exchange at all.

My embed shows "Embedded checkout is a Pro feature"

The in-page iframe widget (embed.js) is Pro-only. On a Free plan the iframe renders an upgrade notice and fires onError with code: "pro_required". Either upgrade to Pro, or switch to the hosted-link redirect — that works on every plan and is the recommended path for most integrations. Your REST API calls are not gated; only the in-page iframe is.

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

Reference
Changelog

Notable API + docs changes. Additive changes don't bump the webhook apiVersion; breaking changes ship side-by-side with a deprecation window.

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.