easyway API
Accept crypto payments in a few lines of code. One REST API, JSON in and out, and signed webhooks that tell your server when the money has arrived.
Machine-readable spec: /openapi.json (OpenAPI 3.1) — import it into Postman, Insomnia or a client generator.
Get an API keyOverview
A payment on easyway is an invoice. You create one from your server with the amount your customer owes, redirect them to its payment_url, and easyway does the rest: the customer picks a coin and network, gets a one-off address (or a shared address plus a memo on TON and XRP), and the on-chain watchers credit the payment the moment it is final. Your server learns about it from a webhook — never by trusting the browser.
| Step | What happens |
|---|---|
| 1. Create | POST /v1/invoices with the amount. You get back an id and a payment_url. |
| 2. Pay | Redirect the customer to payment_url (or open it in a new tab / iframe). The page updates itself while the payment confirms. |
| 3. Confirm | You receive invoice.paid on your webhook URL. Verify the signature, then fulfil the order. (invoice.underpaid if they sent less.) |
| 4. Settle | Funds land in your easyway balance, minus the processing fee. Withdraw with POST /v1/payouts or from the dashboard — to wallets you own. |
Amounts are always decimal strings ("149.00", never 149) so nothing is lost to floating point. Ids are prefixed and unguessable: inv_… invoices, po_… payouts, evt_… webhook events. Timestamps are ISO-8601 in UTC.
Store plugins (no code)
Run a shop or a billing system? Download the plugin for your platform, upload it, paste your API key and webhook secret from the dashboard, and "Pay with crypto" appears at checkout. Orders are marked paid by the signed webhook. Install steps for each one are on the plugins page.
WooCommerce
v0.1.0 · betaWordPress 6.0+, WooCommerce 7.0+, PHP 7.4+, store currency USD
OpenCart
v0.1.0 · betaOpenCart 4.0.0.0 – 4.0.2.x, PHP 8.0+ with curl and json, HTTPS
Odoo
v17.0.0.1.0 · betaOdoo 17 (Community or Enterprise) with the payment module, prices in USD
Authentication
Create keys in Dashboard → Developers. A key is shown once. Send it as a bearer token:
Authorization: Bearer sk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # (X-Api-Key: sk_live_… is accepted too)
| Mode | The key decides: sk_test_… keys only ever see sandbox data, sk_live_… keys only real money. There is no mode parameter. |
| Scopes | Each key has scopes: invoices:read, invoices:write, balances:read, payouts:read, payouts:write. Give a server only what it needs — a key that can create invoices does not need to move money. |
| IP allowlist | Optional per key. With one set, requests from any other IP are refused (403 ip_not_allowed). |
| Project details | Optional per key: a business name, a support email and a webhook. Running several shops on one account? Give each its own key — the checkout of every invoice created with that key shows that name and support address (and underpayment alerts go there) instead of the ones in Settings → Business, and its invoice events go to that key's webhook. |
| Rate limit | 300 requests per minute per key. Over it you get 429 rate_limited; the RateLimit-* headers tell you when to retry. |
| Keep it secret | Never ship a key in a browser, mobile app or public repo. Rotate it from the dashboard if it leaks — revoking is instant. |
Quickstart
Three steps: create an invoice, send the customer to it, act on the webhook. A full walk-through for one coin, with sandbox testing and a go-live checklist: accept USDT TRC-20 payments.
1. Create an invoice
curl -X POST https://api.easyway.cash/v1/invoices \
-H "Authorization: Bearer $EASYWAY_API_KEY" \
-H "Idempotency-Key: order-2048" \
-H "Content-Type: application/json" \
-d '{"amount":"149.00","currency":"USD","order_id":"2048","description":"Order #2048","customer_email":"jane@example.com","success_url":"https://shop.example.com/thanks","cancel_url":"https://shop.example.com/cart","metadata":{"cart":"c_81f2"}}'Response 201:
{
"id": "inv_7Q2mN4kL8pR1sT9uV3wX0y",
"status": "pending",
"mode": "live",
"order_id": "2048",
"description": "Order #2048",
"amount": "149", // trailing zeros dropped
"currency": "USD",
"pay_asset": null, // filled once the customer picks a coin
"pay_amount": null,
"pay_address": null,
"pay_memo": null,
"pay_network": null,
"rate": null,
"received_amount": "0",
"remaining_amount": "0",
"fee_amount": "0",
"net_amount": "0",
"customer_email": "jane@example.com",
"metadata": { "cart": "c_81f2" },
"payment_url": "https://easyway.cash/pay/inv_7Q2mN4kL8pR1sT9uV3wX0y",
"expires_at": "2026-09-23T13:15:00.000Z",
"paid_at": null,
"created_at": "2026-09-23T12:15:00.000Z"
}2. Send the customer to the payment page
// Server-side redirect (Express)
res.redirect(303, invoice.payment_url);
// …or a button in your own page
<a href={invoice.payment_url}>Pay with crypto</a>After paying, the customer is offered a button back to your success_url. Don't treat that visit as proof of payment — the webhook is.
3. Fulfil on the webhook
# Webhooks are pushed to YOUR server — there is nothing to curl. # The header looks like this: # X-Webhook-Signature: t=1758470400,v1=5f1c…e2a9 # expected = HMAC_SHA256(secret, "<t>.<raw request body>") # Compare v1 with expected in constant time, and reject if |now − t| > 300 s.
sk_test_ key first: sandbox invoices are identical, and the dashboard has a Simulate payment button that fires the real webhook. See Test mode.Invoices
Create an invoice
| Field | Type | Description |
|---|---|---|
| amount | string, required | Decimal string. In currency units: "149.00" USD, or "0.005" when currency is BTC. |
| currency | string | "USD" (default) — the customer chooses the coin and the crypto amount is quoted at payment time — or a coin id such as "USDT_TRC20" to price in that coin (then only that coin is accepted). |
| order_id | string ≤128 | Your reference. Comes back on the invoice and in every webhook; searchable in the list endpoint. |
| description | string ≤500 | Shown to the customer on the payment page. |
| customer_email | Stored on the invoice for your records and support. | |
| success_url | https URL | Where the customer can return after paying. |
| cancel_url | https URL | Where the customer can return if they give up. |
| allowed_assets | string[] | Restrict which coins are offered, e.g. ["USDT_TRC20", "USDT_BEP20"]. Default: everything enabled on your account. |
| metadata | object | Up to 20 string values (≤500 chars each). Returned untouched, e.g. your cart or user id. |
| lifetime | integer 5–1440 | Minutes until the invoice expires unpaid. Default: your account setting (60). |
Pricing in a coin instead of USD:
curl -X POST https://api.easyway.cash/v1/invoices \
-H "Authorization: Bearer $EASYWAY_API_KEY" \
-H "Idempotency-Key: topup-7731" \
-H "Content-Type: application/json" \
-d '{"amount":"25","currency":"USDT_TRC20","order_id":"topup-7731","lifetime":30}'The invoice object
| Field | Type | Description |
|---|---|---|
| id | string | inv_ + 22 characters. Also the last part of payment_url. |
| status | string | pending → confirming → paid; or underpaid, expired, cancelled. See lifecycle below. |
| mode | string | live or test. |
| amount / currency | string | What you asked for, as a canonical decimal string: trailing zeros are dropped ("149.00" comes back as "149"), USD keeps at most 2 decimals. |
| pay_asset | string | null | The coin the customer chose (e.g. USDT_TRC20); null until they choose. |
| pay_amount | string | null | How much of that coin they must send — quoted at selection and locked for the invoice's lifetime. |
| pay_address | string | null | The deposit address. Unique to this invoice on every network except TON and XRP. |
| pay_memo | string | null | When non-null, the customer must attach it (TON comment / XRP destination tag). The payment page shows it and encodes it in the QR. |
| rate | string | null | USD price of one unit of pay_asset at quote time. |
| received_amount | string | Confirmed on-chain amount so far, in pay_asset. |
| remaining_amount | string | What is still owed (0 once the full amount arrived). Non-zero with status underpaid — and also on a paid invoice that came in short within your underpayment tolerance, so decide on status, not on this field. |
| fee_amount / net_amount | string | easyway's processing fee and what was credited to your balance, in pay_asset. |
| payment_url | string | The hosted payment page for this invoice. |
| expires_at / paid_at / created_at | ISO-8601 | UTC timestamps. |
Lifecycle
| Status | Meaning | What you should do |
|---|---|---|
| pending | Created; nothing seen on-chain yet. | Nothing. Expires at expires_at if unpaid. |
| confirming | A payment was seen but is not final yet (waiting for confirmations). | Nothing — you get invoice.confirming so you can show \"payment detected\". |
| paid | Final. The amount (within your underpayment tolerance, default 1%) is on your balance. | Fulfil the order on invoice.paid. This is the only status that means money. |
| underpaid | Final, but less than the amount arrived. remaining_amount says how much is missing. | The credited part is on your balance. The customer can send the rest to the same address from the same payment page; a top-up that arrives up to 7 days after expires_at is still credited and turns the invoice paid. An underpaid invoice never changes to expired — it stays underpaid until topped up, so you decide whether to fulfil partially, wait, or refund. |
| expired | Nobody paid before expires_at. | The address is watched for 7 more days: a payment that arrives in that window is still credited and the invoice flips to paid (or underpaid) — so keep handling invoice.paid for old ids. |
| cancelled | You cancelled it (dashboard or API) while it was pending. | Nothing — but if a coin was already chosen, its address stays watched until 7 days after expires_at: a payment the customer still sends is credited and the invoice flips to paid (or underpaid). Handle that like a late payment. |
pay_amount, and pick a tolerance in Dashboard → Settings that suits your margins. Every case with a handler you can copy: underpaid and late payments.Retrieve an invoice
curl https://api.easyway.cash/v1/invoices/inv_7Q2mN4kL8pR1sT9uV3wX0y \ -H "Authorization: Bearer $EASYWAY_API_KEY"
List invoices
| Field | Type | Description |
|---|---|---|
| page | integer | 1-based, default 1. |
| pageSize | integer | 1–100, default 20. |
| order_id | string | Only invoices with exactly this order_id. |
| from / to | YYYY-MM-DD | Only invoices created in this range of UTC days, both inclusive. Either side may be left out. |
curl https://api.easyway.cash/v1/invoices?page=1&pageSize=50&order_id=2048 \ -H "Authorization: Bearer $EASYWAY_API_KEY"
{ "data": [ { …invoice… } ], "total": 1, "page": 1, "page_size": 50 }Cancel an invoice
curl -X POST https://api.easyway.cash/v1/invoices/inv_7Q2mN4kL8pR1sT9uV3wX0y/cancel \
-H "Authorization: Bearer $EASYWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'Once a payment has been detected the invoice can't be cancelled (409 invoice_not_pending).
Coins & networks
Every (coin, network) pair has its own id — USDT on TRON and USDT on Ethereum are different balances, because they are different things to withdraw. Use these ids in currency, allowed_assets, pay_asset and payouts.
| id | Coin | Network | Confirmations | Min payout |
|---|---|---|---|---|
| USDT_TRC20 | USDT | TRON · TRC-20 | 19 | 0.5 USDT |
| USDT_BEP20 | USDT | BNB Smart Chain · BEP-20 | 15 | 0.5 USDT |
| USDT_ERC20 | USDT | Ethereum · ERC-20 | 12 | 0.5 USDT |
| USDT_TON | USDT | TON (memo) | 1 | 0.5 USDT |
| USDT_SOL | USDT | Solana · SPL | 1 | 0.5 USDT |
| USDT_POLYGON | USDT | Polygon PoS | 64 | 0.5 USDT |
| USDT_AVAX | USDT | Avalanche · C-Chain | 10 | 0.5 USDT |
| USDC_ERC20 | USDC | Ethereum · ERC-20 | 12 | 0.5 USDC |
| USDC_BEP20 | USDC | BNB Smart Chain · BEP-20 | 15 | 0.5 USDC |
| USDC_SOL | USDC | Solana · SPL | 1 | 0.5 USDC |
| USDC_POLYGON | USDC | Polygon PoS (native USDC) | 64 | 0.5 USDC |
| USDC_AVAX | USDC | Avalanche · C-Chain (native USDC) | 10 | 0.5 USDC |
| DAI_ERC20 | DAI | Ethereum · ERC-20 | 12 | 0.5 DAI |
| FDUSD_BEP20 | FDUSD | BNB Smart Chain · BEP-20 | 15 | 0.5 FDUSD |
| PYUSD_ERC20 | PYUSD | Ethereum · ERC-20 | 12 | 0.5 PYUSD |
| USDS_ERC20 | USDS | Ethereum · ERC-20 | 12 | 0.5 USDS |
| USDE_ERC20 | USDe | Ethereum · ERC-20 | 12 | 0.5 USDe |
| LINK_ERC20 | LINK | Ethereum · ERC-20 | 12 | 0.05 LINK |
| UNI_ERC20 | UNI | Ethereum · ERC-20 | 12 | 0.08 UNI |
| AAVE_ERC20 | AAVE | Ethereum · ERC-20 | 12 | 0.004 AAVE |
| ONDO_ERC20 | ONDO | Ethereum · ERC-20 | 12 | 1.3 ONDO |
| ENA_ERC20 | ENA | Ethereum · ERC-20 | 12 | 2.6 ENA |
| CRV_ERC20 | CRV | Ethereum · ERC-20 | 12 | 1.6 CRV |
| GRT_ERC20 | GRT | Ethereum · ERC-20 | 12 | 27 GRT |
| SKY_ERC20 | SKY | Ethereum · ERC-20 | 12 | 8 SKY |
| MNT_ERC20 | MNT | Ethereum · ERC-20 | 12 | 1.1 MNT |
| ARB_ERC20 | ARB | Ethereum · ERC-20 | 12 | 3.5 ARB |
| WLD_ERC20 | WLD | Ethereum · ERC-20 | 12 | 1.4 WLD |
| MANA_ERC20 | MANA | Ethereum · ERC-20 | 12 | 7 MANA |
| SAND_ERC20 | SAND | Ethereum · ERC-20 | 12 | 11 SAND |
| IMX_ERC20 | IMX | Ethereum · ERC-20 | 12 | 4 IMX |
| GALA_ERC20 | GALA | Ethereum · ERC-20 | 12 | 310 GALA |
| FET_ERC20 | FET | Ethereum · ERC-20 | 12 | 3.5 FET |
| JASMY_ERC20 | JASMY | Ethereum · ERC-20 | 12 | 140 JASMY |
| SPX_ERC20 | SPX | Ethereum · ERC-20 | 12 | 1.4 SPX |
| XCN_ERC20 | XCN | Ethereum · ERC-20 | 12 | 150 XCN |
| APE_ERC20 | APE | Ethereum · ERC-20 | 12 | 5 APE |
| CAKE_BEP20 | CAKE | BNB Smart Chain · BEP-20 | 15 | 0.25 CAKE |
| TWT_BEP20 | TWT | BNB Smart Chain · BEP-20 | 15 | 1.2 TWT |
| SHIB_ERC20 | SHIB | Ethereum · ERC-20 | 12 | 100000 SHIB |
| PEPE_ERC20 | PEPE | Ethereum · ERC-20 | 12 | 120000 PEPE |
| BONK_SOL | BONK | Solana · SPL | 1 | 150000 BONK |
| WIF_SOL | WIF | Solana · SPL | 1 | 3 WIF |
| RENDER_SOL | RENDER | Solana · SPL | 1 | 0.3 RENDER |
| JUP_SOL | JUP | Solana · SPL | 1 | 2 JUP |
| PYTH_SOL | PYTH | Solana · SPL | 1 | 8 PYTH |
| JTO_SOL | JTO | Solana · SPL | 1 | 1.4 JTO |
| RAY_SOL | RAY | Solana · SPL | 1 | 0.35 RAY |
| W_SOL | W | Solana · SPL | 1 | 53 W |
| FARTCOIN_SOL | FARTCOIN | Solana · SPL | 1 | 5.2 FARTCOIN |
| NOT_TON | NOT | TON (memo) · Notcoin jetton | 1 | 1000 NOT |
| DOGS_TON | DOGS | TON (memo) · DOGS jetton | 1 | 10000 DOGS |
| CATI_TON | CATI | TON (memo) · Catizen jetton | 1 | 8 CATI |
| HMSTR_TON | HMSTR | TON (memo) · Hamster Kombat jetton | 1 | 3500 HMSTR |
| BTC | BTC | Bitcoin | 2 | 0.00001 BTC |
| ETH | ETH | Ethereum | 12 | 0.0002 ETH |
| BNB | BNB | BNB Smart Chain | 15 | 0.0007 BNB |
| POL | POL | Polygon PoS | 64 | 4 POL |
| AVAX | AVAX | Avalanche C-Chain | 10 | 0.05 AVAX |
| TRX | TRX | TRON | 19 | 1.5 TRX |
| SOL | SOL | Solana | 1 | 0.005 SOL |
| XRP | XRP | XRP Ledger (destination tag) | 1 | 0.35 XRP |
| LTC | LTC | Litecoin | 4 | 0.008 LTC |
| DOGE | DOGE | Dogecoin | 6 | 5 DOGE |
| DASH | DASH | Dash | 4 | 0.01 DASH |
| GRAM | GRAM | TON (memo) | 1 | 0.35 GRAM |
Minimum payouts are about half a dollar on every network. “Confirmations” is how many blocks a payment must be buried under before it is credited: seconds on Solana, TON, XRP and Avalanche; about a minute on TRON and BSC; a few minutes on Ethereum, Polygon, Litecoin, Dogecoin and Dash; ~20 minutes on Bitcoin. On TON and XRP all payments arrive at one address and are told apart by the memo / destination tag in pay_memo.
Webhooks
Running several projects? Give each API key its own webhook (Dashboard → Developers → API keys → Create / Edit): events of invoices created with that key go to that URL only, signed with that endpoint's own secret. Everything else — payouts, invoices made in the dashboard or with payment links, and keys without a webhook of their own — goes to the account webhooks (Developers → Account webhooks). Billing a SaaS? The crypto payments for SaaS guide shows a renewal flow built on these events. Either way, pick the events and copy the signing secret. easyway POSTs a JSON body to it:
{
"id": "evt_5Gz2Kq8Lm1Np4Rs7Tv0Wx3",
"type": "invoice.paid",
"mode": "live",
"created": 1758630900,
"data": {
"invoice": { …the invoice object, exactly as GET /v1/invoices/:id returns it… }
}
}| Event | When |
|---|---|
| invoice.confirming | A payment was seen on-chain and is waiting for confirmations. |
| invoice.paid | The payment is final and credited. Fulfil the order. |
| invoice.underpaid | A final payment arrived, but less than the amount. data.invoice.remaining_amount says how much is missing. |
| invoice.expired | The invoice expired unpaid. |
| invoice.cancelled | The invoice was cancelled. |
| payout.sent | A withdrawal was broadcast. data.payout has the tx_hash. |
| payout.failed | A withdrawal could not be sent; the funds are back on your balance. data.payout.failure_reason says why. |
| webhook.test | Sent by the “Send test” button in the dashboard. |
Verify the signature
Every delivery carries X-Webhook-Signature: t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, t + "." + raw_body). Recompute it over the raw request body, compare in constant time, and reject anything older than five minutes. Without this check anyone who finds your URL can mark their own orders as paid. Step-by-step guide with a Next.js handler, a local test and common mistakes: verify webhook signatures.
# Webhooks are pushed to YOUR server — there is nothing to curl. # The header looks like this: # X-Webhook-Signature: t=1758470400,v1=5f1c…e2a9 # expected = HMAC_SHA256(secret, "<t>.<raw request body>") # Compare v1 with expected in constant time, and reject if |now − t| > 300 s.
Delivery rules
| Respond fast | Answer 2xx within 10 seconds, then do the work. Anything else (timeouts, 4xx, 5xx) counts as a failure. |
| Retries | A failed delivery is tried up to 8 times in about 45 hours, retrying after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h. You can also retry any delivery by hand from the dashboard, which shows every attempt, status code and error. |
| Idempotent handlers | The same event can reach you twice (a retry after a slow 200). Store event.id and skip repeats — or make your handler naturally idempotent (“set order 2048 to paid” is safe to run twice). |
| Order | Events are not guaranteed to arrive in order. Decide from the invoice's status in the payload, not from which event came last. |
| Source of truth | If in doubt, GET /v1/invoices/:id. The payload is a snapshot from the moment the event was created. |
Balances, rates & assets
curl https://api.easyway.cash/v1/balances \ -H "Authorization: Bearer $EASYWAY_API_KEY"
{ "data": [
{ "asset": "USDT_TRC20", "available": "1240.55", "locked": "500" },
{ "asset": "BTC", "available": "0.0312", "locked": "0" }
] }available is what you can withdraw now; locked is reserved by payouts that are still in flight.
curl https://api.easyway.cash/v1/rates \ -H "Authorization: Bearer $EASYWAY_API_KEY"
{ "data": { "USDT_TRC20": "1", "BTC": "67412.18000000", "ETH": "3188.40000000", "TRX": "0.34319100", … } }USD per unit, the median of several exchanges, refreshed every minute. Stablecoins are pinned to 1. These are indicative — an invoice locks its own rate at the moment the customer picks a coin.
curl https://api.easyway.cash/v1/assets \ -H "Authorization: Bearer $EASYWAY_API_KEY"
{ "fee_bps": 50,
"data": [
{ "id": "USDT_TRC20", "symbol": "USDT", "name": "Tether", "network": "TRON", "network_label": "TRON · TRC-20",
"memo_required": false, "decimals": 6, "confirmations": 19, "payout_fee": "1", "min_payout": "0.5" },
…
] }The coins you can use right now for new invoices and payouts — the same list as Coins & networks, straight from the server, so your integration never offers a coin that isn't enabled. fee_bps is your processing fee in basis points (50 = 0.5%); payout_fee is charged in the asset itself on top of each payout.
Payouts
A payout moves coins from your easyway balance to a wallet of yours (or anyone's). In live mode a payout can only go to an address you have whitelisted in the dashboard with 2FA — so a leaked API key can never drain your balance to a stranger. Whitelisting takes effect after a short cooldown.
| Field | Type | Description |
|---|---|---|
| asset | string, required | A coin id from Coins & networks. |
| address | string, required | Destination. Validated for that network (checksums included); in live mode it must be whitelisted. |
| memo | string ≤120 | TON comment / XRP destination tag, when the destination (an exchange) needs one. In live mode the whitelist entry's memo is used. |
| amount | string, required | In the asset's units, at most its on-chain precision, and at least the minimum payout. The network fee is charged on top. |
| note | string ≤255 | For your records; visible in the dashboard. |
curl -X POST https://api.easyway.cash/v1/payouts \
-H "Authorization: Bearer $EASYWAY_API_KEY" \
-H "Idempotency-Key: settle-2026-09-23" \
-H "Content-Type: application/json" \
-d '{"asset":"USDT_TRC20","address":"TJRabPrwbZy45sbavfcjinPJC18kjpRTv8","amount":"500","note":"Daily settlement"}'{
"id": "po_3Hd8Kq1Lm5Np7Rs9Tv2Wx4",
"status": "pending",
"mode": "live",
"asset": "USDT_TRC20",
"address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
"memo": null,
"amount": "500",
"network_fee": "1",
"tx_hash": null,
"failure_reason": null,
"created_at": "2026-09-23T12:20:00.000Z"
}Amount + network fee are reserved (locked) immediately. Then: pending → processing (being signed) → sent with a tx_hash, or failed with the funds back on your balance. Subscribe to payout.sent / payout.failed, or poll:
curl https://api.easyway.cash/v1/payouts/po_3Hd8Kq1Lm5Np7Rs9Tv2Wx4 \ -H "Authorization: Bearer $EASYWAY_API_KEY"
| Field | Type | Description |
|---|---|---|
| page | integer | 1-based, default 1. Newest first. |
| pageSize | integer | 1–100, default 20. |
| status | string | Only payouts in this state: pending, processing, sent, failed or cancelled. |
| asset | string | Only payouts of this coin id (coins that are no longer enabled included). |
| from / to | YYYY-MM-DD | Only payouts created in this range of UTC days, both inclusive. Either side may be left out. |
curl https://api.easyway.cash/v1/payouts?page=1&pageSize=50&status=sent&from=2026-09-01&to=2026-09-30 \ -H "Authorization: Bearer $EASYWAY_API_KEY"
Returns { data: [payout…], total, page, page_size } for the key's mode — handy for reconciling your settlement records: page through one month with from / to until you have total rows. A day is a UTC day.
tx_hash and need no whitelist, so you can automate settlement end to end before going live.A walk-through with idempotent retries, the payout webhooks and a go-live checklist: send crypto payouts with the API.
Payment links & hosted checkout
No code at all: create a payment link in Dashboard → Payment links (fixed price, or let the customer type an amount within limits) and share https://easyway.cash/pay/link/pl_…. Each visit mints a normal invoice, so your webhooks fire exactly as for API-created ones.
The hosted checkout page (payment_url) works on phones and desktops, shows a QR code that pre-fills address, amount and memo in wallet apps, polls for the payment itself, and handles under-payments by asking for the remainder. It is safe to open in an <iframe> on your domain.
Idempotency
Networks fail. If your request to create an invoice or payout times out, you can't know whether it went through — unless you sent an Idempotency-Key. Then a retry with the same key and body returns the original result instead of creating a second one. Use something that identifies the action on your side: an order id, a settlement batch id.
Idempotency-Key: order-2048 # 8–128 chars (A–Z a–z 0–9 _ - : .), unique per action
| Situation | Response |
|---|---|
| First request | Processed normally; the response is stored under the key for 24 hours. |
| Retry, same body | The stored response (same status code, same id) — nothing is created twice. |
| Retry, different body | 422 idempotency_key_reused — the key is bound to the first body. |
| Retry while the first is still running | 409 request_in_progress — wait a moment and retry. |
Errors
Errors use conventional HTTP status codes and a small JSON body. error is a stable machine-readable code — match on it, not on the message.
HTTP/1.1 400 Bad Request
{
"error": "validation_error",
"fields": ["amount"]
}fields (validation errors only) names the inputs that were rejected; other errors carry a human-readable message instead.
| Status | error | Meaning |
|---|---|---|
| 400 | validation_error | A field is missing or malformed; `fields` lists which. |
| 400 | invalid_idempotency_key | Idempotency-Key must be 8–128 characters: letters, digits, _ - : . |
| 400 | unknown_currency / unknown_asset | Not one of the ids in Coins & networks. |
| 400 | asset_not_allowed | The invoice's allowed_assets doesn't include it. |
| 400 | amount_too_small / amount_too_large | Below the coin's precision or above 1,000,000 USD. |
| 400 | invalid_address | The payout address is not valid for that network (checksum included). |
| 400 | below_minimum_payout | See the min payout column. |
| 401 | invalid_api_key | Missing, malformed, revoked, or a test key used against live data. |
| 403 | missing_scope | The key was created without the scope this endpoint needs. |
| 403 | ip_not_allowed | The key has an IP allowlist and this IP isn't on it. |
| 403 | address_not_whitelisted | Live payouts only go to addresses approved in the dashboard with 2FA. |
| 403 | payouts_temporarily_locked | Payouts are frozen for this account (e.g. 2FA was just disabled). |
| 404 | invoice_not_found / payout_not_found | Wrong id, or it belongs to another merchant or mode. |
| 409 | invoice_not_pending / invoice_expired | The invoice can no longer be changed. |
| 409 | request_in_progress | The same Idempotency-Key is still being processed — retry in a moment. |
| 422 | insufficient_funds | Available balance is below amount + network fee. |
| 422 | idempotency_key_reused | Same Idempotency-Key, different request body. |
| 429 | rate_limited | Over 300 requests per minute on this key. Back off and retry. |
| 503 | rate_unavailable | No fresh exchange rate for a non-stablecoin right now — retry shortly. |
5xx and network errors: retry with exponential back-off (1 s, 2 s, 4 s…) and the same Idempotency-Key.
Test mode & go-live
Everything exists twice: test and live. Test keys, test invoices, test webhooks and test balances never touch a blockchain. Switch the dashboard to Test mode (top bar) to see and manage sandbox data. A test plan for paid, underpaid, expired and late payments: test crypto payments in sandbox mode.
| In test mode | |
|---|---|
| Invoices | Created the same way, with realistic addresses that nobody controls. Open the invoice in the dashboard and press Simulate payment — empty pays what is due, a smaller amount makes it underpaid. It runs the real crediting code and fires your webhooks. |
| Payouts | Settle instantly with a fake tx_hash; no whitelist needed. |
| Rates | Live rates when available, otherwise a static fallback, so tests never block. |
| Webhooks | A separate list of endpoints. Use the Send test button to check your signature code. |
Go-live checklist
| 1 | Create a live API key with only the scopes your server needs, and an IP allowlist if your servers have fixed IPs. |
| 2 | Add a live webhook endpoint over HTTPS, verify the signature, respond 2xx fast, and make the handler idempotent. |
| 3 | Fulfil only on invoice.paid (and decide your policy for invoice.underpaid). Never trust the success_url visit. |
| 4 | Enable 2FA on your account and whitelist the wallet addresses you will withdraw to (the cooldown starts then, not when you first need the money). |
| 5 | Send an Idempotency-Key with every POST and retry failed requests with the same key. |
| 6 | Make a real payment of a few dollars end to end — the first live invoice is the best test there is. |
Questions? support@easyway.cash — what to include is on the contact page, and live API and watcher health on system status.