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
- 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. - The customer pays from their own wallet: the checkout page shows a QR code, the address, the exact amount and the official token contract.
- Moonvex watches the network: it detects the transfer, waits for the required number of confirmations and cross-checks the transaction against two independent nodes.
- You receive the
payment.confirmedwebhook and can fulfill the order. Your amount is credited to the project balance: the customer pays the Moonvex fee on top. - You withdraw the balance with a payout to your address, from the dashboard or via the API.
Quickstart
- Sign up in the dashboard, confirm your email and enable two-factor authentication.
- Go to “Integration” → “Create key” → type “Secret”. The key is shown only once: store it on your server in the
MOONVEX_SECRET_KEYenvironment variable. - On the same page, add a webhook: an endpoint on your server (https only). Store the signing secret in
MOONVEX_WEBHOOK_SECRET. - 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" }
}'- Receive the
payment.confirmedwebhook, verify its signature (see Webhooks) and mark the order as paid. - 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
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_…
| Type | Prefix | Use |
|---|---|---|
| Secret | sk_live_ | Server side: creating payments, payouts, webhooks — everything within the granted scopes. |
| Publishable | pk_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.
| Scope | Allows |
|---|---|
payments:read | reading payments and subscriptions |
payments:write | creating and cancelling payments, plans and subscriptions |
payouts:read | reading payouts |
payouts:write | creating and cancelling payouts |
balances:read | reading balances |
webhooks:manage | managing webhook endpoints and the delivery log |
wallets:write | issuing 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: trueheader; no second payment is created. - The same key with a different body returns
409with the codeIDEMPOTENCY_KEY_REUSED. - Keys are stored for 30 days.
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. currencyis what the amount is denominated in: a coin (USDT,BTC…) orUSD. ForUSD,asset(the coin used for payment) is required; the rate is locked when the invoice is created and is shown inpricing.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.05rather than26.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
amountis the customer’s total: your price plus your project’s fee. Your price stays inpricing.amount, and what you earn on full payment is inmerchantAmount. See Fee and total amount.
Networks, coins and confirmations
| network | Network | Coins | Confirmations before crediting |
|---|---|---|---|
TRON | TRON (TRC-20) | TRX, USDT | 19 |
ETHEREUM | Ethereum (ERC-20) | ETH, USDT, USDC | 64 (network finality) |
BSC | BNB Smart Chain (BEP-20) | BNB, USDT, USDC | 15 |
SOLANA | Solana (SPL) | SOL, USDT, USDC | 32 (finalized) |
TON | TON (Jetton) | TON, USDT | 1 (masterchain) |
BITCOIN | Bitcoin | BTC | 2 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.
| Coin | Network | Contract |
|---|---|---|
| USDT | TRON | TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t |
| USDT | ETHEREUM | 0xdAC17F958D2ee523a2206206994597C13D831ec7 |
| USDC | ETHEREUM | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
| USDT | BSC | 0x55d398326f99059fF775485246999027B3197955 |
| USDC | BSC | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d |
| USDT | SOLANA | Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB |
| USDC | SOLANA | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v |
| USDT | TON | EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs |
Payments
Create a payment
/v1/paymentskey scope: payments:write| Field | Required | Description |
|---|---|---|
orderId | yes | Your order ID (up to 128 characters: letters, digits, - _ . : / #). One active payment per order. |
amount | yes | Amount as a string: "25", "49.90". |
currency | yes | What the amount is denominated in: a coin or USD. |
network | yes | Payment network: TRON, ETHEREUM, BSC, SOLANA, TON, BITCOIN. |
asset | for USD | The coin used for payment when the amount is in USD. |
customerId | no | Your customer ID, used for search and subscriptions. |
customerEmail | no | Customer email. |
expiresInMinutes | no | Payment window: 5–1440 minutes, 30 by default. |
description | no | Up to 500 characters. |
metadata | no | Your 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:
{
"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" }
}| Field | Meaning |
|---|---|
id | Payment ID (pay_…). Store it. |
address | The address to pay to. Unique for each payment. |
paymentUrl | A ready-made checkout page for the customer. |
amount | Total due from the customer: your amount plus your project’s fee. USDT and USDC are precise to the cent (rounded up). |
merchantAmount | What you receive if the customer pays in full. |
receivedAmount | How much has been received so far. |
contract / tokenStandard | The official token contract and standard (null for a native coin). |
fee | The 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. |
netAmount | How much was credited to your balance after the fee. |
confirmations / requiredConfirmations | Network confirmations: current / required. |
txHashes | Hashes of the customer’s transactions. |
pricing | The 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:
- The price in the coin. For
currency: "USD"it is converted at the locked rate and rounded up to the coin’s smallest unit. - For USDT and USDC, rounded up to whole cents.
- Divided by (1 − rate) and rounded up to the coin’s smallest unit.
- 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:
/v1/payments/quotekey scope: payments:read{
"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:
{
"orderId": "order-1002",
"amount": "49.90",
"currency": "USD",
"asset": "BTC",
"network": "BITCOIN"
}Retrieve a payment
/v1/payments/:idkey scope: payments:readList payments
/v1/payments?status=&orderId=&limit=&cursor=key scope: payments:readNewest 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=50Cancel a payment
/v1/payments/:id/cancelkey scope: payments:writeOnly 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.
/v1/deposit-addresseskey scope: wallets:writecurl 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
customerIdreturns 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
networkslists both). Bitcoin, Solana and TON have their own addresses. - Only coins listed in
assetswith 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);
}/v1/deposit-addresses?customerId=key scope: payments:read/v1/payments?customerId=user-42&mode=DEPOSITkey scope: payments:readThe customer’s addresses and their deposit history.
Payment statuses
| Status | What happened | What to do |
|---|---|---|
CREATED | The invoice was created; waiting for the transfer. | Show the customer the checkout page. |
PENDING | Waiting for the transfer (this is how a payment is returned right after creation). | — |
DETECTED | The transfer was seen on the network. | You can show “payment received, waiting for confirmations”. Do not deliver yet. |
PARTIALLY_PAID | Part of the amount has arrived. | The customer can pay the rest to the same address. |
CONFIRMING | The transaction is in a block; confirmations are accumulating. | Wait. |
CONFIRMED | Paid in full; the funds are credited. | Deliver the order. |
OVERPAID | More 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. |
UNDERPAID | The 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. |
EXPIRED | The payment window ended with no payment. | Create a new payment for the same orderId if needed. |
CANCELLED | You cancelled the payment. | — |
FAILED | The network transaction failed. | — |
REFUNDED | The 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 innetAmount. - Late payments. Funds that arrive after
EXPIREDare 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 onDETECTED.
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):
/v1/public/checkout/:id/v1/public/checkout/:id/eventsThe 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.
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.
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" }
}idis the event ID. It stays the same on redelivery: process each event only once.datais the full object: a payment forpayment.*(as inGET /v1/payments/:id), a payout forpayout.*.Moonvex-Signatureis 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
2xxcode 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.testevent.
Events
| Event | When |
|---|---|
payment.created | The payment was created. |
payment.detected | A transfer was seen on the network (including a partial one). |
payment.confirming | The transaction is in a block; confirmations are accumulating. |
payment.confirmed | Paid in full: deliver the order. |
payment.overpaid | More than the amount was paid. |
payment.underpaid | The top-up window closed with less than the full amount paid. |
payment.expired | The payment window ended. |
payment.cancelled | The payment was cancelled. |
payment.failed | The transaction failed. |
deposit.detected | A deposit to a permanent address was seen on the network. |
deposit.confirmed | The deposit was confirmed and credited: top up the customer’s balance. |
payout.created | The payout was accepted. |
payout.processing | The payout passed checks and is being prepared for sending. |
payout.broadcasted | The payout transaction was broadcast to the network. |
payout.confirmed | The payout was confirmed by the network. |
payout.failed | The payout failed or was rejected; the amount was returned to the balance. |
subscription.created | The subscription was created. |
subscription.renewed | The subscription period was paid and renewed. |
subscription.past_due | The period ended but was not paid: send the customer pendingPayment.url. |
subscription.expired | The subscription was not paid by the end of the grace period. |
subscription.cancelled | The 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.
/v1/payoutskey scope: payouts:writecurl 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"
}'| Field | Description |
|---|---|
asset | Coin. |
network | Network. |
address | Recipient address. Validated against the network; token contract addresses are rejected. |
amount | With feeMode add (default) — what the recipient gets; with deduct — exactly what leaves your balance. |
feeMode | add — the fee is on top of the amount; deduct — the fee is taken out of the amount. Optional, add by default. |
externalId | Your unique payout ID (a duplicate returns 409 DUPLICATE_EXTERNAL_ID). |
metadata | Optional; your own data. |
A 202 response means the payout was accepted and is processed asynchronously; progress is reported via payout.* events.
{
"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.amountleaves your balance. WithfeeMode: "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,totalis held as reserved. - Insufficient balance returns
409with the codeINSUFFICIENT_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.
| Status | Meaning |
|---|---|
CREATED / RISK_CHECK | Accepted; checks in progress. |
PENDING_APPROVAL | Awaiting manual approval. |
APPROVED / QUEUED / SIGNING | Being prepared for sending. |
BROADCASTED / CONFIRMING | Sent; waiting for network confirmation. |
COMPLETED | Completed. txHash is the transaction hash. |
FAILED / REJECTED / CANCELLED | Not 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:
/v1/payouts/quote?asset=USDT&network=TRON&amount=100&feeMode=deductkey scope: payouts:read{
"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.
/v1/payouts/:idkey scope: payouts:read/v1/payouts/:id/cancelkey scope: payouts:writeA payout can be cancelled before it is signed (statuses up to and including QUEUED).
Balances and rates
/v1/wallets/balanceskey scope: balances:readavailable 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" }
]
}/v1/rates?assets=BTC,ETHUSD 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).
/v1/planskey scope: payments:write/v1/subscriptionskey scope: payments:write/v1/subscriptions/:idkey scope: payments:read/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_…" }
}| Status | Meaning |
|---|---|
TRIAL | Trial period (trialDays); no payment needed. |
ACTIVE | Paid until currentPeriodEnd. The invoice for the next period is issued in advance. |
PAST_DUE | The period is unpaid; a pendingPayment is available to pay. |
EXPIRED | Not paid within the grace period (graceDays, 3 days by default). |
CANCELLED | Cancelled by you. |
Webhooks via the API
/v1/webhooks/endpointskey scope: webhooks:manage/v1/webhooks/endpointskey scope: webhooks:manage/v1/webhooks/endpoints/:id/rotate-secretkey scope: webhooks:manage/v1/webhooks/endpoints/:idkey scope: webhooks:manage/v1/webhooks/deliveries?limit=50key scope: webhooks:manage/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" }
}
}| HTTP | code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid parameters. Details are in message and details. |
| 401 | UNAUTHENTICATED | The key is missing, invalid, revoked or expired. |
| 403 | FORBIDDEN | The key lacks the scope, the IP is not allowed, or the project is inactive. |
| 404 | NOT_FOUND | No such payment/payout in your project. |
| 409 | CONFLICT | State conflict: see details.code. |
| 429 | RATE_LIMITED | Too many requests: retry later. |
| 500 | INTERNAL_ERROR | An error on our side: retry with the same Idempotency-Key. |
| details.code | Meaning |
|---|---|
DUPLICATE_ORDER | This orderId already has an active or paid payment. |
IDEMPOTENCY_KEY_REUSED | The Idempotency-Key was already used with a different body. |
ASSET_NETWORK_NOT_ENABLED | This coin is not accepted on this network. |
SYSTEM_PAUSED | Payments or payouts are temporarily paused. |
INSUFFICIENT_BALANCE | The balance does not cover the payout plus the fee. |
INVALID_ADDRESS | The address is not valid for the network (or it is a contract address). |
DUPLICATE_EXTERNAL_ID | A payout with this externalId already exists. |
PAYMENT_NOT_FOUND / PAYOUT_NOT_FOUND | No such object in your project. |
Rate limits
| Requests | Limit per key |
|---|---|
| Reads (GET) | 100 per second |
| Creating and cancelling payments and subscriptions | 20 per second |
| Payouts | 2 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
- The secret key lives only on the server; scopes are the minimum required; IP restriction is enabled where possible.
- Every POST has an Idempotency-Key built from your own identifier.
- The webhook signature is verified against the raw body; events older than 5 minutes are rejected.
- Each event is processed once (by
id); the 2xx response is sent quickly, and processing happens in a queue. - Orders are delivered only on
payment.confirmed/payment.overpaid(or the status from the API), not when the customer returns to your site. - There is a clear customer flow for
payment.underpaid. - The customer is shown the payment’s
amount(including the fee), while reconciliation with the order usesorderIdandpricing.amount, notamount. - A real small payment has been tested on every network you enable.