moonvex

API documentation

Everything you need to accept crypto payments with Moonvex, from your first key to payouts. The API base URL is https://api.moonvex.org; the format is JSON, the encoding is UTF-8.

How it works

  1. Your server creates a payment: order ID, amount, coin and network. The response contains the address to pay to and paymentUrl, a ready-made checkout page.
  2. The customer pays from their own wallet: the checkout page shows a QR code, the address, the exact amount and the official token contract.
  3. Moonvex watches the network: it detects the transfer, waits for the required number of confirmations and cross-checks the transaction against two independent nodes.
  4. You receive the payment.confirmed webhook and can fulfill the order. Your amount is credited to the project balance: the customer pays the Moonvex fee on top.
  5. You withdraw the balance with a payout to your address, from the dashboard or via the API.
There is no test network: Moonvex runs on mainnets only. Test your integration with small amounts (for example, 1 USDT on TRON). Such a test payment is a regular payment, and the funds are credited to your balance.

Quickstart

  1. Sign up in the dashboard, confirm your email and enable two-factor authentication.
  2. Go to “Integration” → “Create key” → type “Secret”. The key is shown only once: store it on your server in the MOONVEX_SECRET_KEY environment variable.
  3. On the same page, add a webhook: an endpoint on your server (https only). Store the signing secret in MOONVEX_WEBHOOK_SECRET.
  4. Create a payment and send the customer to paymentUrl:
curl https://api.moonvex.org/v1/payments \
  -H "Authorization: Bearer $MOONVEX_SECRET_KEY" \
  -H "Idempotency-Key: order-1001-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{
    "orderId": "order-1001",
    "amount": "25",
    "currency": "USDT",
    "network": "TRON",
    "customerId": "user-42",
    "description": "Order #1001",
    "expiresInMinutes": 30,
    "metadata": { "cart": "A17" }
  }'
  1. Receive the payment.confirmed webhook, verify its signature (see Webhooks) and mark the order as paid.
  2. Before delivering high-value goods, you can double-check the status with GET /v1/payments/:id.

Node.js SDK

The official @moonvex/node package: every API method, TypeScript types, webhook signature verification, and retries on network failures with the same Idempotency-Key. Zero dependencies, Node.js 18+.

npm install @moonvex/node
Node.js
import { Moonvex, verifyWebhook } from '@moonvex/node';

const moonvex = new Moonvex({ apiKey: process.env.MOONVEX_SECRET_KEY });

// payment
const payment = await moonvex.payments.create(
  { orderId: 'order-1001', amount: '25', currency: 'USDT', network: 'TRON' },
  { idempotencyKey: 'order-1001-attempt-1' },
);
res.redirect(payment.paymentUrl);

// webhook (express.raw: the signature is computed over the raw body)
app.post('/moonvex/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const event = verifyWebhook(req.body, req.get('Moonvex-Signature'), process.env.MOONVEX_WEBHOOK_SECRET);
  if (event.type === 'payment.confirmed') markOrderPaid(event.data.orderId);
  res.sendStatus(200);
});

Methods: payments.create / get / quote / list / listAll / cancel, depositAddresses.create / list, payouts.create / get / quote / cancel, balances.list, rates.get, plans.upsert, subscriptions.create / get / cancel, webhooks.endpoints.*, webhooks.deliveries.*, verifyWebhook. Errors are thrown as MoonvexError with the fields status, code, detailsCode and requestId.

API keys

Every request passes the key in a header:

Authorization: Bearer sk_live_ruahuFSGZyeu4rI5_…
TypePrefixUse
Secretsk_live_Server side: creating payments, payouts, webhooks — everything within the granted scopes.
Publishablepk_live_Read-only (write scopes cannot be granted to it). It can see all of the project’s payments, so it is also best kept on the server.

Key scopes

Choose the scopes when you create a key (if none are selected, the key gets all of them). A request without the required scope receives 403 FORBIDDEN.

ScopeAllows
payments:readreading payments and subscriptions
payments:writecreating and cancelling payments, plans and subscriptions
payouts:readreading payouts
payouts:writecreating and cancelling payouts
balances:readreading balances
webhooks:managemanaging webhook endpoints and the delivery log
wallets:writeissuing permanent deposit addresses to customers
  • Allowed IPs. You can restrict a key to a list of IPs or subnets; requests from other addresses are rejected.
  • The key is shown only once. If you lose it, create a new one and revoke the old one in the dashboard; revocation takes effect immediately.
  • Never put a secret key in website code, a mobile app or a repository.

Idempotency

Creating a payment, payout or subscription requires the Idempotency-Key header: a unique key for the operation (8–128 characters: letters, digits, _ - : .). It protects against duplicate payments and payouts when a request is retried after a timeout.

  • A retry with the same key and the same body returns the same response with the Idempotent-Replayed: true header; no second payment is created.
  • The same key with a different body returns 409 with the code IDEMPOTENCY_KEY_REUSED.
  • Keys are stored for 30 days.
A good practice is to build the key from your own identifier: order-1001-attempt-1. Then a retry after a network failure is guaranteed not to create a second invoice.

Amounts, coins and networks

  • All amounts are strings with a decimal point: "25", "0.00125". Do not send floating-point numbers.
  • Responses return amounts with the coin’s full precision: USDT on TRON has 6 decimals (25.000000), BTC has 8.
  • currency is what the amount is denominated in: a coin (USDT, BTC…) or USD. For USD, asset (the coin used for payment) is required; the rate is locked when the invoice is created and is shown in pricing.rate.
  • Pricing in one cryptocurrency with payment in another is not supported.
  • USDT and USDC are always whole cents. The amount due is rounded up to 0.01 (for example, 26.05 rather than 26.041667). BTC, ETH, BNB, SOL, TON and TRX use the coin’s full precision: 0.01 BTC would be too coarse a step.
  • Fee on top. The payment’s amount is the customer’s total: your price plus your project’s fee. Your price stays in pricing.amount, and what you earn on full payment is in merchantAmount. See Fee and total amount.

Networks, coins and confirmations

networkNetworkCoinsConfirmations before crediting
TRONTRON (TRC-20)TRX, USDT19
ETHEREUMEthereum (ERC-20)ETH, USDT, USDC64 (network finality)
BSCBNB Smart Chain (BEP-20)BNB, USDT, USDC15
SOLANASolana (SPL)SOL, USDT, USDC32 (finalized)
TONTON (Jetton)TON, USDT1 (masterchain)
BITCOINBitcoinBTC2 up to $1,000, 3 up to $10,000, 6 above that or for amounts in BTC

Official token contracts

Only tokens with these contracts are credited. A token with the same name but a different contract is counterfeit and is not credited.

CoinNetworkContract
USDTTRONTR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
USDTETHEREUM0xdAC17F958D2ee523a2206206994597C13D831ec7
USDCETHEREUM0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
USDTBSC0x55d398326f99059fF775485246999027B3197955
USDCBSC0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d
USDTSOLANAEs9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB
USDCSOLANAEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
USDTTONEQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs

Payments

Create a payment

POST/v1/paymentskey scope: payments:write
FieldRequiredDescription
orderIdyesYour order ID (up to 128 characters: letters, digits, - _ . : / #). One active payment per order.
amountyesAmount as a string: "25", "49.90".
currencyyesWhat the amount is denominated in: a coin or USD.
networkyesPayment network: TRON, ETHEREUM, BSC, SOLANA, TON, BITCOIN.
assetfor USDThe coin used for payment when the amount is in USD.
customerIdnoYour customer ID, used for search and subscriptions.
customerEmailnoCustomer email.
expiresInMinutesnoPayment window: 5–1440 minutes, 30 by default.
descriptionnoUp to 500 characters.
metadatanoYour own data: up to 50 keys; values can be a string, number, true/false or null. Returned in the payment and in webhooks.

The 201 response is the payment object:

Payment
{
  "id": "pay_UsLiLbyGM7YUbdy6BsUL5a",
  "object": "payment",
  "status": "PENDING",
  "mode": "INVOICE",
  "orderId": "order-1001",
  "customerId": "user-42",
  "amount": "26.050000",
  "merchantAmount": "25.008000",
  "asset": "USDT",
  "network": "TRON",
  "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
  "tokenStandard": "TRC-20",
  "address": "TCnQo6fMf9LL8bjhiJMPq6DZ38GbAEoeDs",
  "paymentUrl": "https://pay.moonvex.org/pay/pay_UsLiLbyGM7YUbdy6BsUL5a",
  "pricing": { "currency": "USDT", "amount": "25", "rate": null },
  "receivedAmount": "0.000000",
  "fee": { "percent": "4", "expectedAmount": "1.042000", "amount": null },
  "netAmount": null,
  "confirmations": 0,
  "requiredConfirmations": 19,
  "txHashes": [],
  "expiresAt": "2026-11-10T19:30:00.000Z",
  "settledAt": null,
  "createdAt": "2026-11-10T19:00:00.000Z",
  "metadata": { "cart": "A17" }
}
FieldMeaning
idPayment ID (pay_…). Store it.
addressThe address to pay to. Unique for each payment.
paymentUrlA ready-made checkout page for the customer.
amountTotal due from the customer: your amount plus your project’s fee. USDT and USDC are precise to the cent (rounded up).
merchantAmountWhat you receive if the customer pays in full.
receivedAmountHow much has been received so far.
contract / tokenStandardThe official token contract and standard (null for a native coin).
feeThe Moonvex fee at your project’s rate (each project has its own). The customer pays it on top. expectedAmount is the fee on full payment; amount is the actual fee after payment.
netAmountHow much was credited to your balance after the fee.
confirmations / requiredConfirmationsNetwork confirmations: current / required.
txHashesHashes of the customer’s transactions.
pricingThe original price: currency, amount and rate (for USD).

Fee and total amount

The Moonvex fee is paid by the customer on top. Your project’s rate applies to the total the customer pays (fee = rate × total), so total = price / (1 − rate), not price × (1 + rate): at 4 % the customer pays 26.05 for a price of 25, not 26.00. Each project has its own rate, and it is locked into the payment when the payment is created.

How amount is calculated:

  1. The price in the coin. For currency: "USD" it is converted at the locked rate and rounded up to the coin’s smallest unit.
  2. For USDT and USDC, rounded up to whole cents.
  3. Divided by (1 − rate) and rounded up to the coin’s smallest unit.
  4. For USDT and USDC, rounded up to whole cents again. This is amount.

On payment the fee = amount × rate, rounded down; you get amount minus the fee. Every rounding works in your favour, so you never get less than your price, usually a fraction of a cent more. Example at 4 %: 25 / 0.96 = 26.041666… → 26.05; fee 26.05 × 0.04 = 1.042; you get 25.008 instead of 25 — the extra 0.008 comes from rounding to cents. The exact amount you will receive is in merchantAmount.

To get the total in advance (for example, to show it in the cart) without creating a payment:

GET/v1/payments/quotekey scope: payments:read
Response
{
  "object": "payment_quote",
  "asset": "USDT",
  "network": "TRON",
  "merchantAmount": "25.008000",
  "fee": { "percent": "4", "amount": "1.042000" },
  "total": "26.050000",
  "pricing": { "currency": "USDT", "amount": "25", "rate": null }
}

For a USD price, the response uses the current rate; a created payment locks in its own rate.

An amount in USD paid in BTC:

POST /v1/payments
{
  "orderId": "order-1002",
  "amount": "49.90",
  "currency": "USD",
  "asset": "BTC",
  "network": "BITCOIN"
}

Retrieve a payment

GET/v1/payments/:idkey scope: payments:read

List payments

GET/v1/payments?status=&orderId=&limit=&cursor=key scope: payments:read

Newest first. limit is 1–100 (20 by default). For the next page, pass nextCursor from the response.

curl "https://api.moonvex.org/v1/payments?status=CONFIRMED&limit=50" \
  -H "Authorization: Bearer $MOONVEX_SECRET_KEY"

# response: { "data": [ ...payments... ], "hasMore": true, "nextCursor": "eyJ..." }
# next page: ?cursor=eyJ...&status=CONFIRMED&limit=50

Cancel a payment

POST/v1/payments/:id/cancelkey scope: payments:write

Only possible before the customer has started paying (statuses CREATED and PENDING). After cancellation, you can create a new payment for the same orderId, just as after expiration.

Deposit addresses

For services where the customer has a balance or account: marketplaces and prepaid services, usage-based SaaS and APIs, hosting and cloud, ad platforms, wallets and exchangers, games and digital goods. Each of your customers gets their own permanent address. The customer can top it up at any time with any amount, with no invoice and no expiry. Each confirmed deposit is credited to you separately, minus the fee, and you receive a deposit.confirmed webhook with the customerId, which you use to top up the customer’s balance on your side.

POST/v1/deposit-addresseskey scope: wallets:write
curl https://api.moonvex.org/v1/deposit-addresses \
  -H "Authorization: Bearer $MOONVEX_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "customerId": "user-42", "network": "TRON" }'

# 201 on the first request, 200 if the customer already has an address (the same address)
{
  "object": "deposit_address",
  "customerId": "user-42",
  "address": "TJ8nqPbE2s4yLxq6mJ7vYpB9cX3dQkWz1a",
  "networks": ["TRON"],
  "assets": [
    { "asset": "TRX", "network": "TRON", "contract": null },
    { "asset": "USDT", "network": "TRON", "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" }
  ],
  "createdAt": "2026-11-11T10:00:00.000Z"
}
  • One customer, one address per network. A repeated request for the same customerId returns the same address (no Idempotency-Key needed). Different customers get different addresses.
  • One address per network family. A TRON address accepts TRX and USDT; a single EVM address accepts coins on both Ethereum and BNB Smart Chain (the response’s networks lists both). Bitcoin, Solana and TON have their own addresses.
  • Only coins listed in assets with official contracts are credited. Counterfeit tokens and dust are not credited.
  • Confirmations are the same as for regular payments; for Bitcoin it is 6 (the deposit amount is not known in advance).
  • The address is permanent: show it to the customer in their profile; it can be used for years.

How deposits arrive

Each deposit is a separate payment object with mode: "DEPOSIT" (no orderId and no expiry). Events: deposit.detected means the transfer was seen on the network (you can show “deposit on its way”); deposit.confirmed means it was confirmed and credited. The amount is in receivedAmount, and the amount after the fee is in netAmount. A deposit has no amount known in advance, so the fee is not added on top here; instead, it is deducted from the deposit at your project’s rate.

// deposit.confirmed webhook: event.data is a payment object with mode = "DEPOSIT"
{
  "id": "01a1c8…",
  "type": "deposit.confirmed",
  "data": {
    "id": "pay_9Qm…", "mode": "DEPOSIT", "status": "CONFIRMED",
    "customerId": "user-42", "asset": "USDT", "network": "TRON",
    "address": "TJ8nqPbE2s4yLxq6mJ7vYpB9cX3dQkWz1a",
    "receivedAmount": "50.000000", "fee": { "percent": "4", "amount": "2.000000" }, "netAmount": "48.000000",
    "txHashes": ["7f3a…"], "orderId": null, "...": "…"
  }
}

// credit the customer's balance on your side, exactly once per event.id:
if (event.type === 'deposit.confirmed') {
  await creditCustomerOnce(event.id, event.data.customerId, event.data.asset, event.data.receivedAmount);
}
GET/v1/deposit-addresses?customerId=key scope: payments:read
GET/v1/payments?customerId=user-42&mode=DEPOSITkey scope: payments:read

The customer’s addresses and their deposit history.

Payment statuses

StatusWhat happenedWhat to do
CREATEDThe invoice was created; waiting for the transfer.Show the customer the checkout page.
PENDINGWaiting for the transfer (this is how a payment is returned right after creation).—
DETECTEDThe transfer was seen on the network.You can show “payment received, waiting for confirmations”. Do not deliver yet.
PARTIALLY_PAIDPart of the amount has arrived.The customer can pay the rest to the same address.
CONFIRMINGThe transaction is in a block; confirmations are accumulating.Wait.
CONFIRMEDPaid in full; the funds are credited.Deliver the order.
OVERPAIDMore than the amount arrived (beyond the tolerance). The amount actually received is credited.Deliver the order; how to handle the excess is up to you.
UNDERPAIDThe top-up window (30 minutes after expiry) closed with less than the full amount received. The amount actually received is credited.Decide: deliver part of the order, ask for the rest with a separate invoice, or refund.
EXPIREDThe payment window ended with no payment.Create a new payment for the same orderId if needed.
CANCELLEDYou cancelled the payment.—
FAILEDThe network transaction failed.—
REFUNDEDThe payment was refunded.—
  • 0.5% tolerance. An amount within ±0.5% of the invoice is treated as an exact payment (wallets sometimes round). The fee is always calculated from the amount actually received, so if the customer underpays within the tolerance, you receive slightly less than merchantAmount; the exact amount is in netAmount.
  • Late payments. Funds that arrive after EXPIRED are not credited automatically: Moonvex support reviews them and credits them manually. Set a generous expiry if your customers pay from exchanges.
  • Wrong network or coin. A transfer of the wrong coin, or on a different network, to the payment address is not credited automatically and also goes to manual review.
  • Block reorgs. If a transaction disappears from the network before finality, the payment goes back to waiting. That is why you should deliver only on CONFIRMED / OVERPAID, not on DETECTED.

Checkout page

paymentUrl (https://pay.moonvex.org/pay/pay_…) is a ready-made page: amount, address, QR code, official token contract, timer and confirmation steps. The status updates automatically. Open it in a new tab or send it to the customer as a link (in a messenger or by email). It cannot be embedded in an iframe on your site: the page forbids this for security reasons.

If you want your own checkout design, public data is available without a key (only what the customer sees):

GET/v1/public/checkout/:id
GET/v1/public/checkout/:id/events

The first returns the amount, coin, network, address, contract, status, confirmations and expiry; the second is a stream of status updates (Server-Sent Events). The limit is 60 requests per minute per IP.

The customer returning to your site after paying is not proof of payment. Mark an order as paid only based on a webhook or the status from GET /v1/payments/:id.

Webhooks

Moonvex sends a POST with JSON to your endpoint whenever a payment, payout or subscription changes. The endpoint is configured in the dashboard (“Integration” → “Webhooks”) or via the API; https only.

Request to your server
POST /moonvex/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Moonvex-Webhooks/1.0
Moonvex-Event-Id: 01a1c34d-0e9f-721e-8579-db6f436c4a38
Moonvex-Delivery-Id: 01a1c34d-153c-7989-8fdf-517de0d81590
Moonvex-Signature: t=1794279267,v1=5f2b…c81e

{
  "id": "01a1c34d-0e9f-721e-8579-db6f436c4a38",
  "type": "payment.confirmed",
  "apiVersion": "2026-11-01",
  "createdAt": "2026-11-10T19:04:12.000Z",
  "data": { "id": "pay_UsLiLbyGM7YUbdy6BsUL5a", "status": "CONFIRMED", "orderId": "order-1001", "...": "the full payment object" }
}
  • id is the event ID. It stays the same on redelivery: process each event only once.
  • data is the full object: a payment for payment.* (as in GET /v1/payments/:id), a payout for payout.*.
  • Moonvex-Signature is the signature. Always verify it.

Verifying the signature

Header format: t=<unix-time>,v1=<signature>. The signature is an HMAC-SHA256 of the string <t>.<raw request body> using the webhook secret, hex-encoded. Verify against the raw body (before parsing JSON), compare in constant time, and reject events older than 5 minutes. For 24 hours after a secret rotation, the header contains two v1 values, one for the old secret and one for the new; either is valid.

import crypto from 'node:crypto';
import express from 'express';

/** true if the signature is valid and the event is no older than 5 minutes */
function verifyMoonvex(rawBody, header, secret, toleranceSec = 300) {
  const parts = (header || '').split(',').map((p) => p.split('='));
  const t = Number((parts.find(([k]) => k === 't') || [])[1]);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(t + '.').update(rawBody).digest();
  return parts
    .filter(([k]) => k === 'v1')
    .some(([, sig]) => {
      const got = Buffer.from(sig, 'hex');
      return got.length === expected.length && crypto.timingSafeEqual(got, expected);
    });
}

const app = express();
// important: the signature is computed over the raw body, so use express.raw, not express.json
app.post('/moonvex/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyMoonvex(req.body, req.get('Moonvex-Signature'), process.env.MOONVEX_WEBHOOK_SECRET)) {
    return res.status(400).send('bad signature');
  }
  const event = JSON.parse(req.body.toString('utf8'));
  if (await alreadyProcessed(event.id)) return res.sendStatus(200); // repeated delivery
  if (event.type === 'payment.confirmed') await markOrderPaid(event.data.orderId, event.data);
  await rememberProcessed(event.id);
  res.sendStatus(200); // respond with 2xx within 10 seconds
});

Responses and retries

  • Respond with any 2xx code within 10 seconds. Do any lengthy processing after responding (in a queue).
  • On failure (5xx, 429, timeout, network error), deliveries are retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and another 24 h (±20%), about three days in total. A 4xx response (other than 408 and 429) is retried only 3 times.
  • If an endpoint stays unresponsive for a long time, deliveries to it are paused briefly and then resumed.
  • The delivery log with response codes and a “Retry” button is in the dashboard under “Integration”. The “Send test” button there sends a webhook.test event.

Events

EventWhen
payment.createdThe payment was created.
payment.detectedA transfer was seen on the network (including a partial one).
payment.confirmingThe transaction is in a block; confirmations are accumulating.
payment.confirmedPaid in full: deliver the order.
payment.overpaidMore than the amount was paid.
payment.underpaidThe top-up window closed with less than the full amount paid.
payment.expiredThe payment window ended.
payment.cancelledThe payment was cancelled.
payment.failedThe transaction failed.
deposit.detectedA deposit to a permanent address was seen on the network.
deposit.confirmedThe deposit was confirmed and credited: top up the customer’s balance.
payout.createdThe payout was accepted.
payout.processingThe payout passed checks and is being prepared for sending.
payout.broadcastedThe payout transaction was broadcast to the network.
payout.confirmedThe payout was confirmed by the network.
payout.failedThe payout failed or was rejected; the amount was returned to the balance.
subscription.createdThe subscription was created.
subscription.renewedThe subscription period was paid and renewed.
subscription.past_dueThe period ended but was not paid: send the customer pendingPayment.url.
subscription.expiredThe subscription was not paid by the end of the grace period.
subscription.cancelledThe subscription was cancelled.

By default, a webhook receives all events; you can select specific ones when creating it.

Payouts

A payout sends funds from the project balance to any address on a supported network.

POST/v1/payoutskey scope: payouts:write
curl https://api.moonvex.org/v1/payouts \
  -H "Authorization: Bearer $MOONVEX_SECRET_KEY" \
  -H "Idempotency-Key: withdraw-7781" \
  -H "Content-Type: application/json" \
  -d '{
    "asset": "USDT",
    "network": "TRON",
    "address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
    "amount": "150",
    "externalId": "withdraw-7781"
  }'
FieldDescription
assetCoin.
networkNetwork.
addressRecipient address. Validated against the network; token contract addresses are rejected.
amountWith feeMode add (default) — what the recipient gets; with deduct — exactly what leaves your balance.
feeModeadd — the fee is on top of the amount; deduct — the fee is taken out of the amount. Optional, add by default.
externalIdYour unique payout ID (a duplicate returns 409 DUPLICATE_EXTERNAL_ID).
metadataOptional; your own data.

A 202 response means the payout was accepted and is processed asynchronously; progress is reported via payout.* events.

Payout
{
  "id": "po_8Fq2mXr0kD1sVb7nLtYw3c",
  "object": "payout",
  "externalId": "withdraw-7781",
  "status": "CREATED",
  "asset": "USDT",
  "network": "TRON",
  "address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
  "amount": "150.000000",
  "fee": { "percent": "2", "amount": "3.000000" },
  "total": "153.000000",
  "txHash": null,
  "failureReason": null,
  "createdAt": "2026-11-10T19:10:00.000Z",
  "completedAt": null
}
  • Payout fee = recipient amount × your project’s rate, rounded down to the coin’s smallest unit (in your favour). With feeMode: "add" it is charged on top: total = amount + fee.amount leaves your balance. With feeMode: "deduct" exactly the amount you pass leaves your balance: the recipient gets it / (1 + rate), rounded down, and the fee is the rest (it can be one smallest unit higher so the debit equals your amount exactly). While the payout is in progress, total is held as reserved.
  • Insufficient balance returns 409 with the code INSUFFICIENT_BALANCE.
  • Large payouts are reviewed manually (status PENDING_APPROVAL), which may take some time.
  • The network fee is paid by Moonvex; the recipient receives exactly amount.
StatusMeaning
CREATED / RISK_CHECKAccepted; checks in progress.
PENDING_APPROVALAwaiting manual approval.
APPROVED / QUEUED / SIGNINGBeing prepared for sending.
BROADCASTED / CONFIRMINGSent; waiting for network confirmation.
COMPLETEDCompleted. txHash is the transaction hash.
FAILED / REJECTED / CANCELLEDNot completed; the amount was returned to the balance. The reason is in failureReason.

Quote a payout

What the recipient gets, the fee, the total debited and whether your balance covers it — nothing is created:

GET/v1/payouts/quote?asset=USDT&network=TRON&amount=100&feeMode=deductkey scope: payouts:read
Response
{
  "object": "payout_quote",
  "asset": "USDT",
  "network": "TRON",
  "feeMode": "deduct",
  "amount": "98.039215",
  "fee": { "percent": "2", "amount": "1.960785" },
  "total": "100.000000",
  "available": "500.000000",
  "sufficient": true
}

Example at a 2 % rate with feeMode: "deduct": out of 100 USDT the recipient gets 98.039215, the fee is 1.960785, and exactly 100 leaves your balance.

GET/v1/payouts/:idkey scope: payouts:read
POST/v1/payouts/:id/cancelkey scope: payouts:write

A payout can be cancelled before it is signed (statuses up to and including QUEUED).

Balances and rates

GET/v1/wallets/balanceskey scope: balances:read

available can be withdrawn; reserved is held for payouts in progress.

curl https://api.moonvex.org/v1/wallets/balances -H "Authorization: Bearer $MOONVEX_SECRET_KEY"

{
  "data": [
    { "asset": "USDT", "network": "TRON", "available": "1240.500000", "reserved": "153.000000" },
    { "asset": "BTC", "network": "BITCOIN", "available": "0.01840000", "reserved": "0.00000000" }
  ]
}
GET/v1/rates?assets=BTC,ETH

USD rates (median across several exchanges) for BTC, ETH, BNB, TRX, SOL, TON, USDT and USDC, up to 8 coins per request. Any active key works. Rates update roughly every 2 minutes.

curl "https://api.moonvex.org/v1/rates?assets=BTC,ETH" -H "Authorization: Bearer $MOONVEX_SECRET_KEY"

{
  "data": [
    { "asset": "BTC", "quote": "USD", "price": "73189.15", "sources": ["binance", "coinbase", "kraken"] },
    { "asset": "ETH", "quote": "USD", "price": "2026.00", "sources": ["binance", "coinbase", "kraken"] }
  ],
  "ttlSeconds": 120
}

Subscriptions

Recurring payments: each period is a separate invoice that the customer pays via a link. Crypto cannot be debited automatically from the customer’s wallet, so send the customer the pendingPayment.url link (by email or in your app).

PUT/v1/planskey scope: payments:write
POST/v1/subscriptionskey scope: payments:write
GET/v1/subscriptions/:idkey scope: payments:read
POST/v1/subscriptions/:id/cancelkey scope: payments:write
# 1. Plan (created or updated by code)
curl -X PUT https://api.moonvex.org/v1/plans \
  -H "Authorization: Bearer $MOONVEX_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "code": "pro-monthly", "name": "Pro, monthly", "priceAmount": "15", "currency": "USDT",
        "asset": "USDT", "network": "TRON", "intervalDays": 30, "trialDays": 0, "graceDays": 3 }'

# 2. Customer subscription
curl https://api.moonvex.org/v1/subscriptions \
  -H "Authorization: Bearer $MOONVEX_SECRET_KEY" -H "Idempotency-Key: sub-user-42-pro" \
  -H "Content-Type: application/json" \
  -d '{ "planCode": "pro-monthly", "customerId": "user-42" }'

# response
{
  "id": "sub_3Hk9…", "object": "subscription", "status": "PAST_DUE", "plan": "pro-monthly",
  "customerId": "user-42", "currentPeriodEnd": null,
  "pendingPayment": { "id": "pay_…", "url": "https://pay.moonvex.org/pay/pay_…" }
}
StatusMeaning
TRIALTrial period (trialDays); no payment needed.
ACTIVEPaid until currentPeriodEnd. The invoice for the next period is issued in advance.
PAST_DUEThe period is unpaid; a pendingPayment is available to pay.
EXPIREDNot paid within the grace period (graceDays, 3 days by default).
CANCELLEDCancelled by you.

Webhooks via the API

GET/v1/webhooks/endpointskey scope: webhooks:manage
POST/v1/webhooks/endpointskey scope: webhooks:manage
POST/v1/webhooks/endpoints/:id/rotate-secretkey scope: webhooks:manage
DELETE/v1/webhooks/endpoints/:idkey scope: webhooks:manage
GET/v1/webhooks/deliveries?limit=50key scope: webhooks:manage
POST/v1/webhooks/deliveries/:id/replaykey scope: webhooks:manage
# create a notification endpoint (the signing secret is returned only once)
curl https://api.moonvex.org/v1/webhooks/endpoints \
  -H "Authorization: Bearer $MOONVEX_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://shop.example.com/moonvex/webhook", "events": ["payment.confirmed", "payment.expired"] }'

# { "id": "…", "url": "…", "events": [...], "secret": "whsec_…" }

The signing secret is returned only on creation and on rotation (rotate-secret). After rotation, the old secret remains valid for 24 hours, so update it on your side in time.

Errors

Errors always use the same format. Send the requestId to support: it lets us find the request.

{
  "error": {
    "code": "CONFLICT",
    "message": "Order order-1001 already has a payment in progress or is closed",
    "requestId": "req_9e6c8eb939aa40b28d689df40f7d51a6",
    "details": { "code": "DUPLICATE_ORDER" }
  }
}
HTTPcodeWhen
400VALIDATION_ERRORInvalid parameters. Details are in message and details.
401UNAUTHENTICATEDThe key is missing, invalid, revoked or expired.
403FORBIDDENThe key lacks the scope, the IP is not allowed, or the project is inactive.
404NOT_FOUNDNo such payment/payout in your project.
409CONFLICTState conflict: see details.code.
429RATE_LIMITEDToo many requests: retry later.
500INTERNAL_ERRORAn error on our side: retry with the same Idempotency-Key.
details.codeMeaning
DUPLICATE_ORDERThis orderId already has an active or paid payment.
IDEMPOTENCY_KEY_REUSEDThe Idempotency-Key was already used with a different body.
ASSET_NETWORK_NOT_ENABLEDThis coin is not accepted on this network.
SYSTEM_PAUSEDPayments or payouts are temporarily paused.
INSUFFICIENT_BALANCEThe balance does not cover the payout plus the fee.
INVALID_ADDRESSThe address is not valid for the network (or it is a contract address).
DUPLICATE_EXTERNAL_IDA payout with this externalId already exists.
PAYMENT_NOT_FOUND / PAYOUT_NOT_FOUNDNo such object in your project.

Rate limits

RequestsLimit per key
Reads (GET)100 per second
Creating and cancelling payments and subscriptions20 per second
Payouts2 per second
Public checkout page data (no key)60 per minute per IP

When exceeded, you get 429 RATE_LIMITED; retry after a second. Do not poll payment status in a loop: use webhooks.

Machine-readable API description (OpenAPI 3): api.moonvex.org/v1/openapi.json.

Before you go live

  1. The secret key lives only on the server; scopes are the minimum required; IP restriction is enabled where possible.
  2. Every POST has an Idempotency-Key built from your own identifier.
  3. The webhook signature is verified against the raw body; events older than 5 minutes are rejected.
  4. Each event is processed once (by id); the 2xx response is sent quickly, and processing happens in a queue.
  5. Orders are delivered only on payment.confirmed / payment.overpaid (or the status from the API), not when the customer returns to your site.
  6. There is a clear customer flow for payment.underpaid.
  7. The customer is shown the payment’s amount (including the fee), while reconciliation with the order uses orderId and pricing.amount, not amount.
  8. A real small payment has been tested on every network you enable.