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.
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
- 1You call
POST a USD amount to /api/crypto/deposits. We return a hosted payment link.
- 2Customer pays
On the payment page they pick a coin + network and send crypto from any wallet.
- 3We confirm
We watch the chain. Once confirmed, funds are credited to your balance.
- 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.
Your first payment, end to end, in test mode. Four steps — no exchange required.
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.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.
/api/crypto/depositsResponse:
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.)
/api/pay/:token/create-deposit3. 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.)
/api/crypto/deposits/:id/test-complete4. Handle the webhook
Set a webhook URL in API & Webhooks. On completion we POST a signed event — verify it, then update your order.
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.
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.
| Feature | Free | Pro |
|---|---|---|
| Platform fee per payment | 0.1% | 0% |
| Hosted /pay checkout | Included | Included |
| REST API, webhooks, test mode, all ~48 exchanges | Included | Included |
| 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 keys | 1 live + 1 test | Up to 10 |
| Idempotency-Key + metadata | Included | Included |
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.
JSON in, JSON out. Base URL https://easycryptopay.xyz. Auth via Bearer API key.
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.
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.
/api/crypto/depositsRequest
Body parameters
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
Idempotency-Key | string | Optional. Same key + same body within 24 hours returns the cached response. Different body on the same key returns 409. |
Response
Response fields
| Field | Type | Description |
|---|---|---|
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 (0 for Pro plan, 0.1 for Free plan). |
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. |
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.
/api/crypto/deposits/:id/checkPath parameters
| Field | Type | Description |
|---|---|---|
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
Response
Response fields
| Field | Type | Description |
|---|---|---|
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. |
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.
/api/crypto/deposits/:id/test-completeecp_live_*). It is strictly sandbox.Path parameters
| Field | Type | Description |
|---|---|---|
idrequired | string | depositId or paymentToken of a deposit created via a test-mode key. Calling this with a live-mode deposit returns 400. |
Request
Response
Response fields
| Field | Type | Description |
|---|---|---|
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.
/api/invoicesRequest
Body parameters
| Field | Type | Description |
|---|---|---|
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
Invoice object
| Field | Type | Description |
|---|---|---|
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.
/api/invoices/:idList payments
List deposits for your business. Supports filtering by status and currency, plus pagination. Includes aggregate stats across the full result set.
/api/paymentsQuery parameters
| Field | Type | Description |
|---|---|---|
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
Response
Response fields
| Field | Type | Description |
|---|---|---|
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 (0 for Pro plan, 0.1 for Free plan). |
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.
/api/payments/:idGet balance
Current balance plus lifetime volume, fees collected, and pending deposit count. Useful for dashboards, billing reconciliation, and webhook-independent polling.
/api/balanceRequest
Response
Response fields
| Field | Type | Description |
|---|---|---|
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. |
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.
/api/customersQuery parameters
| Field | Type | Description |
|---|---|---|
status | string | Filter by activity: "active" (paid within 30 days) or "inactive". |
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
Response
Response fields
| Field | Type | Description |
|---|---|---|
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 | "active" if they paid within the last 30 days, else "inactive". |
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.
/api/crypto/currenciesRequest
Response
Response fields
| Field | Type | Description |
|---|---|---|
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. |
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
| Event | Description |
|---|---|
deposit.pending | Deposit initiated, awaiting payment. |
deposit.confirming | Payment detected on-chain, waiting for confirmations. |
deposit.completed | Deposit fully confirmed and credited. |
deposit.expired | Deposit window expired without receiving payment. |
deposit.failed | Deposit failed during processing. |
deposit.auto_converted | Pro auto-convert finalised — fires once at terminal state (filled / failed / skipped). Payload mirrors deposit.completed plus an autoConvert sub-object. |
invoice.paid | Invoice fully paid (linked deposit completed). |
invoice.expired | Invoice expired without payment. |
withdrawal.pending | Payout intent created (payouts only; requires payouts_enabled). |
withdrawal.approved | Payout approved and queued for sending. |
withdrawal.processing | Payout submitted to the exchange / chain. |
withdrawal.completed | Payout sent on-chain. |
withdrawal.failed | Payout failed; see data.failureReason. |
webhook.test | Synthetic 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.
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:
t is the Unix timestamp when we signed the request. v1 is HMAC-SHA256 of `${t}.${rawBody}` using your webhook secret, hex-encoded.
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:
Python
PHP
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 |
|---|---|
| Initial | Immediate |
| 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.
(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.
@easycryptopay/node — typed client + signature verifier. Node 18+ or any fetch-capable runtime.
Install
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/checkby 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
Verifying webhook signatures
Related
- Quick start — end-to-end example.
- API reference — what each SDK method wraps.
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.
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.
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:
ReactPRO
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)
| Field | Type | Description |
|---|---|---|
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 "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. |
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:
/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 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
/api/crypto/auto-convertStep-up authentication required. Cookie session only (this is a management endpoint and rejects API-key auth with 403).
Successful response
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:
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;receivedUsdtpopulated.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.
Manual retry
/api/payments/[id]/auto-convert/retryOwner-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.
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
/api/auth/mobile/sign-upMirrors 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
| Field | Type | Description |
|---|---|---|
email | string | Required. Account email. |
password | string | Required. Plain-text password (server hashes with bcrypt). |
name | string | Optional display name. |
device | object | Optional. { deviceId, deviceName, platform: 'android'|'ios', appVersion, osVersion } — used for the sessions list and the per-device session-revoke flow. |
pushToken | string | Optional FCM token; pushProvider defaults to 'fcm'. |
Returns { token, expiresAt, ttlSeconds, user, business }. Errors: 400 invalid email/password, 409 duplicate email.
Sign-in
/api/auth/mobile/sign-inSame 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 flaggedisCurrent.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. PasspushToken: nullto 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) → returnssudoToken, 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.
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 flaggedisTest: trueand excluded from balance, charts, customer rollups, and stats.
Where Bearer keys work
Programmatic access is scoped to the read/write payment surface:
GET /api/crypto/currenciesPOST /api/crypto/deposits·GET /api/crypto/deposits/:id/check·POST /api/crypto/deposits/:id/test-completeGET /api/invoices·POST /api/invoices·GET /api/invoices/:idGET /api/payments·GET /api/payments/:idGET /api/customersGET /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.
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
| Field | Type | Description |
|---|---|---|
400 | Bad Request | Validation failed or business rule violated (e.g. extending a Free plan). |
401 | Unauthorized | Session missing/expired, or sensitive route called outside the step-up grace window (code: STEPUP_REQUIRED). |
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).
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.
IDEMPOTENCY_CONFLICT
Same Idempotency-Key replayed within 24 hours but the body differs. Pick a fresh key.
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.
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:
| Code | HTTP | Meaning |
|---|---|---|
| VALIDATION_FAILED | 400 | Body failed validation; details[] carries the field paths + messages. |
| BAD_REQUEST | 400 | Malformed JSON or a missing required field/header. |
| UNAUTHORIZED | 401 | Missing/invalid key or session. Sign in or check the Authorization header. |
| API_KEY_NOT_ALLOWED | 403 | A Bearer key hit a cookie-only (management) endpoint. |
| PRO_REQUIRED | 403 | A Pro-only feature on a Free plan; payload includes a feature discriminator. |
| NOT_FOUND | 404 | The id doesn't exist or belongs to another business / mode. |
| NO_ACTIVE_BUSINESS | 404/400 | The session has no active business selected (a setup state). |
| NO_DEPOSIT_YET | 409 | test-complete called before a coin+network was picked. Select a currency first. |
| PAYOUTS_DISABLED | 403 | Payouts aren't enabled for this business (or the key lacks the payouts scope). |
| PAYOUT_LIMIT_EXCEEDED | 409 | The rolling 24h payout ceiling would be exceeded. |
| INVALID_ADDRESS | 400 | Withdrawal destination failed per-network format validation. |
| GONE | 410 | A 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
| Field | Type | Description |
|---|---|---|
/api/auth/* | burst=5 | Sign-in, sign-up, password reset, 2FA verification — strict to slow brute-force. |
/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.
All list endpoints (payments, invoices, customers, deliveries) take limit + offset query params and return total alongside the page.
Query params
| Field | Type | Description |
|---|---|---|
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.
POST /api/invoices also accept either shape (snake wins when both are present).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.testevent without creating a deposit. Confirms wiring end-to-end.
Signature verification keeps failing
`${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).
Notable API + docs changes. Additive changes don't bump the webhook apiVersion; breaking changes ship side-by-side with a deprecation window.
- 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.
- 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
apiVersionfield (currently"1"). Additive changes (new fields insidedata) 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.versionat /openapi.json; the Node SDK follows semver. - Field naming. The legacy list endpoints emit both
snake_caseandcamelCasekeys. 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.