Integration Recipe

Nexus Integration Recipe

This guide maps a deposit flow (six steps) and a payout flow (five steps) onto the Nexus REST API calls you make from your backend. Use the Demo environment while you build, then swap the host for production once the flow looks right.

Environments
  Authentication (GraphQL, sign-in only): https://YOUR_EXCHANGE_HOST/graphql
  Nexus API (REST, deposits and payouts): https://YOUR_NEXUS_HOST
Your Demo and Production hostnames are shared with your team during onboarding. Substitute them wherever these placeholders appear below.

Three small things to set up before you can run any of the flows. Do these once when you boot, refresh the token as needed, and you are ready to move on to the Deposits or Payouts tab.

What you need on your side

  • A service_api_key and service_api_secret. These are the credentials you exchange for a JWT in every session.
  • An HTTPS endpoint of yours that can accept POST requests. Nexus will deliver every event to this URL.
  • A way to store small pieces of state per client: their Nexus payment_id, the deposit address you got back, and the webhook_id Nexus generates when you register your URL.
  • An exchange account that is active and has Nexus enabled. If it isn't, every authenticated call returns 403 with the plain-text body User is not authorized.
First call for a new account. Nexus creates its merchant record for your account the first time PATCH /v1/user/checkin is called. The same call syncs newly enabled currencies into your settings. Signing in to the Nexus dashboard makes this call for you. If your account has never been used there, call it once after you authenticate. Until the record exists, other calls fail with User not found.
curl -X PATCH "https://YOUR_NEXUS_HOST/v1/user/checkin" \
  -H "Authorization: Bearer YOUR_JWT"

Authenticate and get a Bearer token do this on every backend boot, then keep refreshing

Every REST call into Nexus carries a Bearer JWT. You get the JWT by calling the service_signin GraphQL mutation with your key and secret. Treat the JWT like a session token. It expires, so refresh it before expires_at.

Request
curl -X POST "https://YOUR_EXCHANGE_HOST/graphql" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation service_signin($key: String!, $secret: String!) { service_signin(service_api_key: $key, service_api_secret: $secret) { jwt expires_at } }",
    "variables": {
      "key":    "YOUR_API_KEY",
      "secret": "YOUR_API_SECRET"
    }
  }'
Response
{
  "data": {
    "service_signin": {
      "jwt": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjQwNDRhZmY0Mi...4h_3nn0kc4",
      "expires_at": 1914331870000
    }
  }
}

The full jwt value goes into Authorization: Bearer ... on every authenticated REST call below. expires_at is a Unix timestamp in milliseconds.


Cache the supported currencies one short call, cache the result

Before you can ask for a deposit address, you need to know which currencies and networks your merchant is enabled for. (Payouts have their own route list; see payout step 2.) One call returns the whole list. Cache it on your side. The list does not change often, so a daily refresh is plenty.

Request
curl -X GET "https://YOUR_NEXUS_HOST/v1/currency" \
  -H "Authorization: Bearer YOUR_JWT"
Response
{
  "success": true,
  "message": "",
  "currencies": [
    {
      "currency_id": "ETH",
      "type": "crypto",
      "network": "SEPOLIA",
      "min_confirmations": 0,
      "max_confirmations": 12,
      "block_time": 15
    }
  ]
}

Field reference. currency_id is what you pass when creating an openDeposit payment, or requesting an address. network is the chain. On the testnet environment Ethereum shows up as SEPOLIA; once you graduate to production the same call will return ETHEREUM (mainnet) and likely additional currencies. max_confirmations is the number of on chain confirmations Nexus waits for before treating a deposit as final.

Below are the six steps of a typical deposit flow, wired up to the exact Nexus calls. You create one openDeposit payment per client and stamp it with your internal client id. Nexus returns a stable address for that payment. Every time the client sends crypto to that address, Nexus confirms it on chain and POSTs you a webhook with your client id echoed back.

Step 1Register your webhook URL with Nexus one-time setup

You give Nexus the URL you want events delivered to. Nexus generates an id for the webhook, which doubles as its signing secret, and starts pushing every deposit and payout event to that URL. Store that id somewhere safe on your server. You will need it to verify every incoming delivery. A merchant can register up to five webhooks. Every enabled webhook receives every event, so one URL is enough for deposits and payouts.

Request
curl -X POST "https://YOUR_NEXUS_HOST/v1/webhook" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "url":   "https://your-server.example.com/webhooks/nexus",
    "label": "Deposits and payouts"
  }'
Response
{
  "success": true,
  "message": "",
  "web_hook": {
    "id":      "8f3c27d4-55b1-4c2e-9a7e-1f0b9a3e4d22",
    "url":     "https://your-server.example.com/webhooks/nexus",
    "enabled": true,
    "label":   "Deposits and payouts",
    "created": "2026-11-06T09:58:12.481Z"
  }
}

Save web_hook.id. This recipe calls it the webhook_id, and it is the secret you hash into every signature check. There is no separate "secret" field in the request body. PUT /v1/webhook/{id} toggles the webhook between enabled and disabled, and DELETE /v1/webhook/{id} removes it. To rotate the secret, register a new webhook, switch your verifier to the new id, then delete the old one.


Step 2Request an address for a client (one client, one address)

Nexus does not take your client id directly when issuing an address. Instead, you create an openDeposit payment once per client and store your client id on it (the field is called merchant_order_id). After that, every time you call the address endpoint with the same payment id and the same currency and network, Nexus returns the same address. That gives you exactly the one client, one address property you want.

So this step maps to two calls. The first happens once per client (a quick provisioning call); the second can happen as often as you like and will keep returning the same address.

2a. Create the openDeposit payment for this client once per client

This is the call that "registers" the client on Nexus's side. Set type to openDeposit so the address can receive any number of deposits of any size. Pass your internal client id as merchant_order_id. Nexus will echo it back on every webhook delivery for this client, which is how you will recognise who deposited.

Request
curl -X POST "https://YOUR_NEXUS_HOST/v1/payment" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "type":                       "openDeposit",
    "pricing_mode":               "convertOnArrival",
    "merchant_order_id":          "YOUR_INTERNAL_CLIENT_ID",
    "merchant_order_metadata":    "{ \"client_id\": \"YOUR_INTERNAL_CLIENT_ID\" }"
  }'

A few notes on the fields. pricing_mode can be longLivedQuote (lock a rate up front) or convertOnArrival (convert at the moment the deposit lands). type and pricing_mode are both required. merchant_order_currency_id is only used for invoice payments; on an openDeposit it is ignored. merchant_order_id is your internal client identifier, up to 36 characters, and it is echoed back on every deposit event. merchant_order_metadata accepts any JSON value (here a string) and is echoed back unchanged. Also optional: merchant_order_description; expiration, a duration such as "30.00:00:00" with a minimum of 1 hour (leave it out so the deposit payment never expires); and passthrough_fees, which defaults to the account setting.

Response
{
  "payment": {
    "id":                            "a427fc2a-be6b-44b4-9ebb-f0041496be70",
    "owner_user_id":                 "674016eb-a47f-4c56-9a09-12fd0bccba4a",
    "status":                        "New",
    "kyt_status":                    null,
    "currency_id":                   null,
    "expiration":                    null,
    "target_currency_amount_required": null,
    "target_currency_amount_paid":   null,
    "merchant_label":                null,
    "merchant_order_id":             "INT_CLIENT_0000001",
    "merchant_order_metadata":       "{ \"client_id\": \"INT_CLIENT_0000001\" }",
    "merchant_order_description":    null,
    "own_merchant_fee":              null,
    "created":                       "2026-04-28T09:00:14.291904+00:00",
    "type":                          "Open Deposit",
    "pricing_mode":                  "Convert On Arrival",
    "fees_in_price":                 true,
    "passthrough_fees":              false,
    "updated":                       "2026-04-28T09:00:14.291904+00:00",
    "expired":                       null
  },
  "success": true,
  "message": ""
}

Store payment.id alongside your client record in your own database. From now on, that single value is what you will use to fetch this client's deposit address, and the merchant_order_id you set is what you will look up when webhooks arrive.

2b. Fetch the deposit address stable, callable any time

Pass the payment id from 2a along with the currency and network you want an address on. The first call generates the address. Every subsequent call with the same payment id, currency, and network returns the very same address. So you can either call it once and cache, or you can call it every time the client opens their deposit page, the result will not change.

Request
curl -X POST "https://YOUR_NEXUS_HOST/v1/client/address" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_id":       "PAYMENT_ID_FROM_STEP_2A",
    "currency":         "ETH",
    "currency_network": "SEPOLIA"
  }'

currency and currency_network must be a pair that was enabled on your account when the payment was created, because each payment keeps a snapshot of the currency settings at that moment. A currency enabled later won't work on an older payment. currency_network is converted to upper case. This endpoint sits under /v1/client, which is the unauthenticated client side namespace. The payment_id itself is the credential. Treat it as semi sensitive on your side, but you do not need a Bearer token to call this endpoint.

Response
{
  "address_id":               "a1ff35ed-276f-4db4-9b35-01f28b97338a",
  "address":                  "0xbC0B3C687496aB9D053b09BFcAfA05C184072aA4",
  "address_currency_id":      "ETH",
  "address_currency_network": "SEPOLIA",
  "tag_type":                 null,
  "tag_value":                null,
  "success":                  true,
  "message":                  ""
}

On the Demo (testnet) environment Ethereum lives under the SEPOLIA network; in production the same call returns an ETHEREUM mainnet address. If you need addresses on multiple chains for the same client (for example, ETH on SEPOLIA and USDT on TRC20), call this endpoint once per (currency, network) combination and store each address under that combination. tag_type and tag_value are null for chains like Ethereum. They are populated only on chains that require a memo, such as XRP or XLM. If you support those chains, you must show the tag to the client alongside the address, and warn them that omitting it will misroute their funds.


Step 3Show the address and network to the client

This step is entirely on your side. You already have everything you need from step 2b. Display the address, the address_currency_network, and (if the chain has one) the tag_value. Be explicit about which network the funds must be sent on (for the testnet flow that is Sepolia, not Ethereum mainnet). Sending crypto on the wrong chain is one of the most common ways funds go missing, and it is not recoverable on your end.

Tip. The address is permanent for that client and that network. You can render it on the deposit page without making a fresh Nexus call every time. Just keep the row in your own database in sync with what step 2b returned the first time.

Step 4Client sends crypto to the address

This step happens entirely off your platform. The client opens their wallet, scans the QR or pastes the address, and broadcasts the transaction. There is nothing to call. Nexus's chain watcher picks the transaction up automatically once it appears on chain.


Step 5Nexus sends you a webhook when the transaction confirms

A deposit produces a series of deliveries, all with the same payment_id and on-chain crypto_transaction_id. A delivery is sent each time the exchange reports a change for the transaction: first when Nexus sees it on chain, then on each further confirmation update, with transaction_status: "Detected" and a growing confirmations count. If your account uses a custom (lower) confirmation count for the currency, you also get "ClientCompleted" when that count is reached. When the exchange marks the deposit final, the status becomes "Completed". "Completed" normally arrives twice: once straight away, then again after Nexus has processed fees and any conversion. The second delivery carries the final parent_fee and, if converted, preferred_currency_amount_received. A transaction the exchange rejects arrives as "Rejected". For an openDeposit, payment_status moves to "Processing" on the first transaction and stays there.

Detected (transaction_status: "Detected"). The deposit has been seen on chain. Do not credit the balance yet.

POST /webhooks/nexus HTTP/1.1
Host: your-server.example.com
Content-Type: application/json
x-webhook-signature: 7c1f4a93b8e2d6f0a5c8b4e7d2a9f6c3b1e8d5a7c4f9b6e3d0a7c4f1b8e5d2a9

{
  "payment_id":                         "23ad8d20-d8bb-4541-81a7-51ee8e61d1c4",
  "payment_status":                     "Processing",
  "transaction_status":                 "Detected",
  "crypto_transaction_id":              "0x3c4f2dca512f44238302678fe881789ec94beba9bc898aa8bd48b6a2ea72f1ea",
  "confirmations":                      1,
  "address":                            "0xbC0B3C687496aB9D053b09BFcAfA05C184072aA4",
  "currency":                           "ETH",
  "currency_network":                   "SEPOLIA",
  "currency_amount_paid":               0.05,
  "parent_fee":                         0,
  "is_converted":                       false,
  "is_overpayment":                     false,
  "preferred_currency_amount_received": null,
  "preferred_currency_id":              "USD",
  "merchant_order_id":                  "INT_CLIENT_0000001",
  "merchant_order_metadata":            "{ \"client_id\": \"INT_CLIENT_0000001\" }"
}

Completed (transaction_status: "Completed"). The deposit is final. Credit the balance on the first Completed delivery for a transaction.

{
  "payment_id":                         "23ad8d20-d8bb-4541-81a7-51ee8e61d1c4",
  "payment_status":                     "Processing",
  "transaction_status":                 "Completed",
  "crypto_transaction_id":              "0x3c4f2dca512f44238302678fe881789ec94beba9bc898aa8bd48b6a2ea72f1ea",
  "confirmations":                      1,
  "address":                            "0xbC0B3C687496aB9D053b09BFcAfA05C184072aA4",
  "currency":                           "ETH",
  "currency_network":                   "SEPOLIA",
  "currency_amount_paid":               0.05,
  "parent_fee":                         0,
  "is_converted":                       false,
  "is_overpayment":                     false,
  "preferred_currency_amount_received": null,
  "preferred_currency_id":              "USD",
  "merchant_order_id":                  "INT_CLIENT_0000001",
  "merchant_order_metadata":            "{ \"client_id\": \"INT_CLIENT_0000001\" }"
}

A few practical things. preferred_currency_id reflects the merchant's preferred-currency setting; on testnet that is currently "USD". preferred_currency_amount_received is only populated when is_converted is true (the deposit was actually converted at arrival); otherwise it stays null regardless of the preferred-currency setting. parent_fee is the parent-merchant fee on the transaction: an estimate on interim deliveries, and the amount actually charged on the final Completed delivery. payment_status and transaction_status are sent as code names (for example "ClientCompleted", with no space). Respond with a 2xx within a few seconds, otherwise Nexus will retry (three attempts in total; see Retry policy). The same delivery can land more than once across retries, so make your handler idempotent; see step 6 for how to dedupe.


Step 6Verify the signature, identify the client, credit the balance

Three things happen in your handler when a delivery lands. They run in this order.

6a. Verify the signature

Compute SHA-256 over webhook_id concatenated with the raw request body, hex encode it, and compare against the x-webhook-signature header. If they do not match, reject the request with 401 and stop. The full signature scheme and a sample implementation are in the Webhook signature verification section near the end. The same code path covers deposit and payout deliveries.

6b. Identify the client

Read merchant_order_id from the payload. That is the value you set in step 2a, so it is your internal client id. Look the client up in your database. If you also stored payment_id on the client row, you can additionally use it as a sanity check.

6c. Credit the balance

Only credit on transaction_status: "Completed". "Detected" and "ClientCompleted" are informational, and the deposit is not yet final. Do nothing on "Rejected". When you do credit, use currency_amount_paid as the amount and currency as the asset. If is_converted is true (the deposit was converted at arrival), preferred_currency_amount_received and preferred_currency_id hold the converted amount and target currency; if is_converted is false, preferred_currency_amount_received stays null regardless of the preferred-currency setting on the merchant account.

Idempotency. Credit at most once per (payment_id, crypto_transaction_id). Record that pair when you credit. When another Completed delivery arrives for the same pair (the second Completed, or a retry), don't credit again. Just update any conversion fields you store. Detected deliveries repeat as confirmations grow, so treat them as status updates.

Reading the payment status on demand public, no Bearer needed

Webhooks are the right way to stay in sync. But occasionally you will want to read the live status of a payment without waiting for a delivery. For that, Nexus exposes a public read endpoint keyed by payment_id.

Request
curl -X GET "https://YOUR_NEXUS_HOST/v1/client/payment/PAYMENT_ID"

Useful for a customer facing "deposit status" screen, or for backfilling state if your webhook endpoint was down for a while. The response is { "success", "message", "payment": { ..., "client_status", ... }, "transactions": [ ... ], ... }. payment.client_status is the payment status, and transactions lists the on-chain transactions detected so far.

Payouts run through the Nexus REST API, on the same host, with the same Bearer token and the same webhook as deposits. A payout has two parts: you create it, which validates the request, quotes the fees and records it as Pending. Then you approve it, which debits your balance and sends the funds on-chain. Nexus pushes a payout.* webhook event at every status change until the payout is payout.completed or payout.rejected.

Endpoint. Every call in this section targets https://YOUR_NEXUS_HOST/v1/payout with the Authorization: Bearer ... header from Before you start. Request and response field names are snake_case. Status values are returned as display strings ("Pending", "Processing" and so on).
Payout lifecycle at a glance
After approval_status execution_status Webhook event
POST /v1/payoutPendingNewpayout.initiated
PATCH .../approveApprovedProcessingpayout.processing
On-chain progress updatesApprovedProcessingpayout.processing (can repeat)
Confirmed on chainApprovedCompletedpayout.completed
Failed after approvalApprovedRejectedpayout.rejected
PATCH .../reject (before approval)RejectedNewpayout.rejected

Step 1Configure payout settings one-time setup

Three account settings decide how a backend-driven payout behaves. Review them once before you send your first payout.

1a. Turn off the per-payout 2FA code (skip_payout_mfa)

By default, approving a payout needs the merchant user's current 6-digit 2FA code (mfa_code). A server can't supply that code on every request. So for an automated integration, the merchant turns skip_payout_mfa on once. That setting can only be changed from the Nexus merchant dashboard in a browser, and turning it on needs a valid 2FA code. Calls from a server or an API key are rejected with 403. After that, approvals need no body at all. A merchant with skip_payout_mfa on also no longer needs 2FA enabled on the exchange account just to create payouts.

Check the current value. Read it with GET /v1/user/settings and look for "skip_payout_mfa": true. If it is false, either have a merchant user turn it on in the dashboard, or plan to send a fresh mfa_code with every approval in Step 4.
Request
curl -X GET "https://YOUR_NEXUS_HOST/v1/user/settings" \
  -H "Authorization: Bearer YOUR_JWT"
Response (payout-related fields)
{
  "payout_passthrough_fees": false,
  "skip_payout_mfa":         true
}

1b. Choose who pays the payout fees (payout_passthrough_fees)

A payout can be charged up to three fees: the exchange's network/withdrawal fee (fee), your parent merchant's fee (parent_merchant_fee) and your own payout fee (own_merchant_fee). The passthrough setting decides how they are applied:

  • false (default): fees are deducted from amount, and the recipient receives final_amount = amount - fee - parent_merchant_fee - own_merchant_fee.
  • true: the recipient receives exactly amount (final_amount = amount). Your account covers the fees.

You can set the account default with PATCH /v1/user/settings/payout-passthrough/{true|false}. You can also override it per payout with the passthrough_fees field in Step 3. If you use passthrough, keep enough free balance to cover both the amount and the fees.

Request
curl -X PATCH "https://YOUR_NEXUS_HOST/v1/user/settings/payout-passthrough/true" \
  -H "Authorization: Bearer YOUR_JWT"

1c. Optional: lock payouts to your server IPs

You can whitelist up to 10 IP addresses or CIDR ranges (minimum /16 for IPv4, /48 for IPv6). Once at least one entry exists, payout create and approve calls from any other IP are rejected with 403. An empty list means no restriction. The IP checked is the one the Nexus load balancer sees, so add your servers' public egress IPs.

Request
curl -X POST "https://YOUR_NEXUS_HOST/v1/user/settings/whitelisted-ips" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "ip_address": "203.0.113.0/24",
    "label":      "Payout workers"
  }'

List entries with GET, edit one with PUT /v1/user/settings/whitelisted-ips/{id} and remove one with DELETE /v1/user/settings/whitelisted-ips/{id}. The whitelist covers only payout create and approve. Reads, fee estimates and rejects work from any IP.

Webhooks. You don't register anything new for payouts. The webhook from deposit step 1 receives every payout event too, and it is signed with the same id.

Step 2Check routes, limits and fees cache routes; check limits and fees per payout

2a. Supported payout routes

This returns the currency and network pairs you can send payouts on. It can differ from the deposit list in Before you start, so build your payout form from this call. If crypto_tag_type is present (for example a memo or destination tag), that route needs a tag on the destination.

Request
curl -X GET "https://YOUR_NEXUS_HOST/v1/payout/payment-routes" \
  -H "Authorization: Bearer YOUR_JWT"
Response
{
  "payment_routes": [
    { "currency": "ETH",  "crypto_network": "SEPOLIA" },
    { "currency": "USDT", "crypto_network": "TRC20" }
  ]
}

2b. Balance and limits for a currency

This returns your free balance, the minimum payout amount and the remaining periodic payout limits for one currency. Call it before you create a payout, so that a request your account cannot fund fails on your side first.

Request
curl -X GET "https://YOUR_NEXUS_HOST/v1/payout/info/ETH" \
  -H "Authorization: Bearer YOUR_JWT"
Response
{
  "account_balance": {
    "free_balance":    1.25,
    "total_balance":   1.30,
    "exposed_balance": 0.05
  },
  "payment_limit": {
    "currency":              "ETH",
    "payout_minimal_amount": 0.001,
    "payout_daily_limit":    5,
    "payout_weekly_limit":   20,
    "payout_monthly_limit":  50
  },
  "pending_payouts_amount": 0.05
}

The payout_*_limit values are the remaining amounts for the current day, week and month, not the configured caps. pending_payouts_amount is the total of payouts you have created but that haven't finished yet. free_balance is the figure that Step 4 checks against.

2c. Estimate the fees optional, read only

This quotes the fees and the amount the recipient would receive for a payout, without creating anything. It uses the same calculation as the create call. Pass passthroughFees to preview either fee mode. If you leave it out, your account default is used.

Request
curl -X GET "https://YOUR_NEXUS_HOST/v1/payout/fee-estimation/ETH/SEPOLIA/0.01?passthroughFees=false" \
  -H "Authorization: Bearer YOUR_JWT"
Response
{
  "fee_estimation": 0.0002,
  "final_amount":   0.0098,
  "fees": {
    "exchange_fee":        0.0002,
    "parent_merchant_fee": 0,
    "merchant_fee":        0
  }
}

fee_estimation is the total of the three fees. The exchange fee here is an estimate. The fee actually charged is fixed at approval and appears as fee on the payout record.


Step 3Create the payout

This call validates the request: your account status, that the route is enabled, that the destination address is valid for the network, the minimum amount and the fee quote. It records the payout as Pending/New. No funds move yet and your balance is not touched.

Request
curl -X POST "https://YOUR_NEXUS_HOST/v1/payout" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "currency_id":         "ETH",
    "network":             "SEPOLIA",
    "amount":              0.01,
    "destination_address": "0x4e83362442B8d1bec281594CEa3050C8eB01311C",
    "note":                "INT_CLIENT_0000001:WD-000123",
    "passthrough_fees":    false
  }'
FieldNotes
currency_id requiredThe asset to send, taken from Step 2a. It is converted to upper case.
network requiredThe chain, exactly as crypto_network in Step 2a (for example SEPOLIA on Demo, TRC20). It is converted to upper case.
amount requiredGross amount in the source currency, greater than 0. After fees it must still be at or above payout_minimal_amount.
destination_address requiredThe recipient address. It is validated against the network, and an address for the wrong chain is rejected at this step.
destination_address_tag_type, destination_address_tag_valueMemo or tag, for routes that return a crypto_tag_type.
noteFree text, up to 256 characters. Put your own reference here (client id, internal withdrawal id). It is echoed in every webhook and you can search on it.
passthrough_feesA per-payout override of the account default from Step 1b.
Response
{
  "id":                  "6f1d2c3b-8a47-4e0f-9b52-0c7e1a9d4f21",
  "currency":            "ETH",
  "network":             "SEPOLIA",
  "amount":              0.01,
  "fee":                 0.0002,
  "parent_merchant_fee": 0,
  "own_merchant_fee":    0,
  "final_amount":        0.0098,
  "destination_address": "0x4e83362442B8d1bec281594CEa3050C8eB01311C",
  "tag_type":            null,
  "tag_value":           null,
  "exchange_payment_id": null,
  "blockchain_hash":     null,
  "note":                "INT_CLIENT_0000001:WD-000123",
  "approval_status":     "Pending",
  "execution_status":    "New",
  "created_at":          "2026-11-06T10:15:02.184Z",
  "updated_at":          "2026-11-06T10:15:02.184Z",
  "kyt":                 null,
  "payout_passthrough_fees": false,
  "is_refund":           false,
  "manual_refund":       null
}

Store id against the client's withdrawal request. It is the payout id for every later call, and every webhook carries it as payout_id. A payout.initiated webhook fires as soon as this call returns.


Step 4Approve the payout

Approval checks your free_balance against amount, books the payout fees, and sends the payout to the exchange for on-chain execution. If it succeeds, the payout moves to Approved/Processing and exchange_payment_id is filled in. If you run payouts straight through, call approve right after create.

Request (skip_payout_mfa on, no body)
curl -X PATCH "https://YOUR_NEXUS_HOST/v1/payout/PAYOUT_ID/approve" \
  -H "Authorization: Bearer YOUR_JWT"
Request (skip_payout_mfa off)
curl -X PATCH "https://YOUR_NEXUS_HOST/v1/payout/PAYOUT_ID/approve" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "mfa_code": "123456" }'

The body is optional. mfa_code is only checked when skip_payout_mfa is off. wallet_id is also optional, and selects which exchange wallet to debit when your account has more than one. Leave it out to use the default wallet.

Response
{
  "id":                  "6f1d2c3b-8a47-4e0f-9b52-0c7e1a9d4f21",
  "currency":            "ETH",
  "network":             "SEPOLIA",
  "amount":              0.01,
  "fee":                 0.000029,
  "parent_merchant_fee": 0,
  "own_merchant_fee":    0,
  "final_amount":        0.009971,
  "destination_address": "0x4e83362442B8d1bec281594CEa3050C8eB01311C",
  "tag_type":            null,
  "tag_value":           null,
  "exchange_payment_id": "019dd623-f9fb-72ab-9163-03d8d87dd90e",
  "blockchain_hash":     null,
  "note":                "INT_CLIENT_0000001:WD-000123",
  "approval_status":     "Approved",
  "execution_status":    "Processing",
  "created_at":          "2026-11-06T10:15:02.184Z",
  "updated_at":          "2026-11-06T10:15:03.902Z",
  "kyt":                 null,
  "payout_passthrough_fees": false,
  "is_refund":           false,
  "manual_refund":       null
}

At approval, fee is replaced by the fee the exchange actually charged, so final_amount can differ slightly from the Step 2c estimate. A payout can be approved only once. A second call returns Withdrawal was already approved or rejected.. If approval fails with a 400 (for example insufficient balance), the payout stays Pending. You can retry the approval once you've fixed the cause, or cancel it with PATCH /v1/payout/{id}/reject.

Heads upDon't leave payouts waiting in Pending. Created payouts that are never approved count toward pending_payouts_amount. If you decide not to send one, reject it. Rejecting is only possible while the payout is Pending. The record is kept for audit and a payout.rejected event fires.

Step 5Verify the signature and finalise on webhooks

Payout events go to the same URL as deposits, with the same x-webhook-signature header, verified with the same webhook id (see Webhook signature verification). Unlike deposit deliveries, payout deliveries come in an envelope: { "event": "payout.*", "data": { ... } }. Branch on event and match data.payout_id to the id you stored in Step 3.

A healthy payout produces payout.initiated (create), then payout.processing (approve), and finally payout.completed once the transaction confirms on chain. payout.processing can arrive more than once while the exchange reports progress. Treat repeats as no-ops.

Sample deliveries

payout.initiated. Fires right after Step 3. Nothing has moved.

{
  "event": "payout.initiated",
  "data": {
    "payout_id":                   "6f1d2c3b-8a47-4e0f-9b52-0c7e1a9d4f21",
    "blockchain_transaction_hash": null,
    "status":                      "payout.initiated",
    "amount":                      0.01,
    "destination_address":         "0x4e83362442B8d1bec281594CEa3050C8eB01311C",
    "exchange_payment_id":         null,
    "note":                        "INT_CLIENT_0000001:WD-000123",
    "currency":                    "ETH",
    "timestamp":                   "2026-11-06T10:15:02.2214567Z",
    "kyt":                         null
  }
}

payout.processing. Fires on approval, and again on each progress update from the exchange. exchange_payment_id is now populated.

{
  "event": "payout.processing",
  "data": {
    "payout_id":                   "6f1d2c3b-8a47-4e0f-9b52-0c7e1a9d4f21",
    "blockchain_transaction_hash": null,
    "status":                      "payout.processing",
    "amount":                      0.01,
    "destination_address":         "0x4e83362442B8d1bec281594CEa3050C8eB01311C",
    "exchange_payment_id":         "019dd623-f9fb-72ab-9163-03d8d87dd90e",
    "note":                        "INT_CLIENT_0000001:WD-000123",
    "currency":                    "ETH",
    "timestamp":                   "2026-11-06T10:15:03.9551204Z",
    "kyt":                         null
  }
}

payout.completed. Fires once the transaction has confirmed. blockchain_transaction_hash is the on-chain txid you can link to a block explorer. This is the event that should mark the payout as final on your side.

{
  "event": "payout.completed",
  "data": {
    "payout_id":                   "6f1d2c3b-8a47-4e0f-9b52-0c7e1a9d4f21",
    "blockchain_transaction_hash": "0x2887de8e5ce88810f7501b24af05a9abdd10c03ad6dc4ce97e86306af1f4fb28",
    "status":                      "payout.completed",
    "amount":                      0.01,
    "destination_address":         "0x4e83362442B8d1bec281594CEa3050C8eB01311C",
    "exchange_payment_id":         "019dd623-f9fb-72ab-9163-03d8d87dd90e",
    "note":                        "INT_CLIENT_0000001:WD-000123",
    "currency":                    "ETH",
    "timestamp":                   "2026-11-06T10:16:41.0087312Z",
    "kyt":                         null
  }
}

payout.rejected. Fires in two situations: you rejected a Pending payout yourself, or the exchange rejected it after approval (compliance, risk or an on-chain failure). In the second case, payout fees that were booked are refunded automatically. In both cases no funds reached the destination, so give the amount back to the client's internal balance.

amount in the webhook is always the gross amount from Step 3. For the net amount and the fee breakdown, read the payout record (below). kyt holds the KYT screening result when screening is enabled on your account, and is null otherwise.

Idempotency. Deliveries can be retried, so the same event may arrive more than once. Key your handler on (payout_id, event) for payout.initiated, payout.completed and payout.rejected. Treat payout.processing as a status update, not an action. Once a payout is completed or rejected on your side, ignore any later delivery for it.

Reading payout state on demand

Use these calls when your handler was down, for reconciliation, or for an operator screen. Both return the same payout object as Steps 3 and 4.

One payout
curl -X GET "https://YOUR_NEXUS_HOST/v1/payout/PAYOUT_ID" \
  -H "Authorization: Bearer YOUR_JWT"
List and search
curl -X GET "https://YOUR_NEXUS_HOST/v1/payout/list?pageIndex=1&pageSize=50&executionStatus=Processing" \
  -H "Authorization: Bearer YOUR_JWT"

The list response is { "results": [ ... ], "total_count": n, "page": n, "size": n }, newest first. pageIndex is 1-based. The optional filters are currency, approvalStatus (Pending, Approved, Rejected), executionStatus (New, Processing, Completed, Rejected), startDate, endDate and search. search matches a payout id or exchange_payment_id exactly, or any payout whose note contains the text. That is how you find a payout by your own reference.

Webhook signature verification

Every delivery, deposit or payout, carries an x-webhook-signature header. The signature is a plain SHA-256 hash, not an HMAC, of the webhook id concatenated with the raw JSON body, hex encoded in lower case. Deposits and payouts arrive on the same registered webhook, so a single secret verifies both. That secret is the web_hook.id from deposit step 1.

The simplest correct implementation, in Node.js / TypeScript:

import crypto from "node:crypto";
import express from "express";

const app = express();

// IMPORTANT: capture the raw body before any JSON parser touches it.
// If your framework re-serialises the body before you hash it, the bytes
// will not match and every delivery will look invalid.
function verify(webhookId: string) {
  return (req, res, next) => {
    const rawBody = req.body.toString("utf8");
    const signature = (req.header("x-webhook-signature") ?? "").toLowerCase();

    const expected = crypto
      .createHash("sha256")
      .update(webhookId + rawBody, "utf8")
      .digest("hex");

    if (
      expected.length !== signature.length ||
      !crypto.timingSafeEqual(
        Buffer.from(expected, "hex"),
        Buffer.from(signature, "hex"),
      )
    ) {
      return res.status(401).send("invalid signature");
    }
    next();
  };
}

// One endpoint, one webhook id, both flows.
app.post(
  "/webhooks/nexus",
  express.raw({ type: "application/json" }),
  verify(NEXUS_WEBHOOK_ID),
  (req, res) => {
    const body = JSON.parse(req.body.toString("utf8"));

    if (typeof body.event === "string" && body.event.startsWith("payout.")) {
      // Payout event: { event: "payout.*", data: { payout_id, status, ... } }.
      // Dedupe on (data.payout_id, event); finalise on payout.completed / payout.rejected.
    } else {
      // Deposit event: flat payload with payment_id + transaction_status / payment_status.
      // Dedupe on (payment_id, crypto_transaction_id, transaction_status).
    }
    res.status(200).end();
  },
);

Two things to get right. First, hash the raw request bytes, not JSON you have parsed and serialised again. Different JSON libraries reorder keys and change whitespace, and any change in the bytes breaks the digest. Second, compare in constant time (for example with timingSafeEqual), so the comparison doesn't leak how much of the signature matched.

Retry policy

If your endpoint returns anything other than a 2xx, or the request fails at the network level, Nexus retries on this schedule:

Attempt Delay
1Immediate
21 second after attempt 1 fails
32 seconds after attempt 2 fails

After the third failure, that delivery is dropped. Retries are short, so if your handler might be slow or flaky, accept the delivery, put it on a queue and process it asynchronously. Don't do the work synchronously and let it fail. If you miss a delivery anyway, use the read endpoints (deposits, payouts) to catch up.

Webhook event reference

Deposit events

Deposit deliveries are flat JSON objects with no event field. There are two kinds. Transaction deliveries carry transaction_status, and these are what an openDeposit integration acts on. Payment deliveries have no transaction_status. They are sent when an invoice reaches ClientCompleted or a completed status, or when a payment is set to Complete/Reject manually (PATCH /v1/payment/{id}/{Complete|Reject}, which produces payment_status "ManualCompleted" / "ManualRejected").

transaction_status When What to do
Detected Seen on chain, and again on each confirmation update Informational. Do not credit.
ClientCompleted Only when the account uses a custom confirmation count for the currency and that count is reached Informational. Do not credit.
Completed The exchange marked the deposit final. Normally delivered twice; the second follows fee and conversion processing. Credit once per (payment_id, crypto_transaction_id).
Rejected The exchange rejected the transaction Do not credit.

Payout events

Payout events arrive on the same webhook as deposits, in a { "event": "payout.*", "data": { ... } } envelope. Use the presence of event to tell them apart from deposits, and the value of event (also repeated as data.status) to tell them apart from each other. Four events cover the lifecycle. Sample deliveries are in payout step 5.

Event Payout state What it means
payout.initiated approval_status: "Pending", execution_status: "New" Fires right after POST /v1/payout. The payout is recorded and no funds have moved. You usually don't need to do anything, since you already saved the record in step 3.
payout.processing approval_status: "Approved", execution_status: "Processing" Fires on approval, and again for each progress update from the exchange. exchange_payment_id is populated. The funds are on their way but not final. This event can repeat.
payout.completed execution_status: "Completed", blockchain_transaction_hash non null The transaction has confirmed on chain and the funds are at the destination. Mark the payout as final on your side when you receive this event.
payout.rejected approval_status: "Rejected" (you rejected it while pending) or execution_status: "Rejected" (the exchange rejected it after approval) The payout will not be sent. If the exchange rejected it, payout fees that were booked are refunded automatically. Give the amount back to the client's internal balance. To see which kind of rejection it was, read the payout with GET /v1/payout/{id}.

Errors and idempotency

500 errors mean no on-chain activity

If a call to the Nexus REST API returns a 5xx, treat it as a failure: nothing was broadcast and no funds moved. For anything except a payout approval, you can retry once the cause has cleared. If an approve call returns a 5xx or times out, read the payout with GET /v1/payout/{id} before retrying. If it is already Approved, do nothing. If it is still Pending and exchange_payment_id is null, retry the approval. If you are unsure, contact Nexus support before sending it again.

Idempotency

Deposit and payout webhook deliveries can arrive more than once. Each state change happens only once on the Nexus side, and the dedupe keys recommended in deposit step 6 and payout step 5 make retries safe.

Create calls are not idempotent. Two POST /v1/payment calls produce two payments, each with its own payment_id and address. Two POST /v1/payout calls produce two payouts. Call each create endpoint at most once per logical request, and store the returned id before you do anything else. If a create call times out, treat the result as unknown. Look for the record before you retry: use GET /v1/payment for deposits, or GET /v1/payout/list?search=<your note reference> for payouts. If the record exists, use it. Approval protects itself: a payout can be approved only once, and a second approve returns an error instead of sending the funds twice.

Common 400 errors

Most errors come back as { "success": false, "message": "..." }, usually with status 400. Two other formats exist:

The ones you are most likely to see during testing are below. Deposit errors come first, then payout errors.

Error message Fires on Cause and fix
'amount_required' is required for this type of invoice (plain text) POST /v1/payment You used type: "invoice" without an amount. For the flow in this recipe the type is openDeposit (no amount needed); double-check the body in deposit step 2a.
'merchant_order_currency_id' is required for this type of invoice (plain text) POST /v1/payment The order currency was missing. Pass a currency_id from GET /v1/currency; on testnet that is ETH.
Can not use order currency [{currency_id}]. POST /v1/payment The currency is null in the merchant's settings or has been disabled. Use a currency the merchant is actually enabled for. If the currency you expect to use is missing, contact Nexus support.
Amount {n} is exceeds decimal precision for currency {currency_id} POST /v1/payment The amount has more decimal places than the currency allows. Round it to the currency's precision.
Can not calculate amount for payment. Merchant does not have default own fee group POST /v1/payment (invoice type), POST /v1/payout, payout fee estimate The merchant has no default fee group (Default_Own). Contact Nexus support to set one up before retrying.
Can not create payment. There are no currencies to pay the invoice POST /v1/payment No deposit-enabled currencies on the merchant. Contact Nexus support to enable at least one.
The payment with id {id} was not found. POST /v1/client/address The payment_id is wrong, or the payment was deleted. Re-check the value returned by deposit step 2a.
Currency not found: CurrencyId: {id}; Network: {network} POST /v1/client/address That currency and network pair doesn't exist for this merchant. Check it against GET /v1/currency first.
Currency {currency_id} with network {network} is not available for payment with id {id}. POST /v1/client/address The pair exists but wasn't enabled on the account when this payment was created. Payments keep a snapshot of the currency settings from creation time. Create a new payment after enabling the currency.
Can not create address for payment with id {id}. POST /v1/client/address The exchange failed to create the address. Retry once. If it persists, contact Nexus support.
Quotes are not supported. (plain text) POST /v1/client/quote You called a quote endpoint on an openDeposit payment. Open deposits do not use quotes; skip the quote call entirely.
Payment with id: [{id}] is expired. GET /v1/client/payment/{id}, POST /v1/client/quote, GET /v1/client/market-price/{id} The payment passed its expiration. A payment created without expiration never expires, so first check that the payment_id is the one you created. If it really has expired, create a new payment and fetch the address again.
User not found Most authenticated endpoints Nexus has no merchant record for the calling account yet. Call PATCH /v1/user/checkin once (see Before you start).
403, empty body POST /v1/payment The account's KYC isn't approved and the exchange doesn't allow payments without KYC, or the API key lacks the permission to create this payment type.
User is not authorized. (403, plain text) Any authenticated endpoint The exchange account is inactive, or Nexus isn't enabled for it. Contact Nexus support.
You should have approved KYC to create the withdrawal POST /v1/payout The merchant account has not passed KYC, and the exchange does not allow payouts without it. Finish KYC, or contact Nexus support.
You should have enabled MFA to create the withdrawal POST /v1/payout 2FA is off on the merchant's exchange account and skip_payout_mfa is off. Either enable 2FA, or turn on skip_payout_mfa in the dashboard (payout step 1a).
Currency {currency} with network {network} is not available for you. POST /v1/payout, approve That currency and network pair is not an enabled payout route. Check it against GET /v1/payout/payment-routes, and use the exact network string returned there.
Provided address for {currency} {network} is not valid POST /v1/payout The destination address failed validation for that network. A common cause is an address for a different chain. Validate addresses when you collect them from the client.
Withdrawal amount is less than min amount POST /v1/payout After fees, the amount is below payout_minimal_amount from GET /v1/payout/info/{currency}. Increase the amount, or turn on passthrough so fees are not deducted.
Validation format, with messages such as 'network' field is required, 'destination_address' field is required, 'currency_id' field is required or 'amount' must not be absent and lower than 0. POST /v1/payout A required field is missing, or amount is not greater than 0. Payouts use destination_address and network, not address or crypto_network.
Failed to create the withdrawal. POST /v1/payout The exchange could not validate the address or quote the fee at that moment. Retry once. If it persists, contact Nexus support.
MFA code is not valid. approve skip_payout_mfa is off, and mfa_code was missing, expired or wrong. Send a current code, or turn on skip_payout_mfa.
Insufficient balance on your account. approve free_balance is below the payout amount. The payout stays Pending. Top up your balance and approve again, or reject the payout.
Withdrawal was already approved or rejected. approve, reject The payout is no longer Pending. Read it with GET /v1/payout/{id} to see its current state. On a retried approval, this means the first one went through.
Failed to process with withdrawal. approve The exchange rejected the transfer, or your balance could not be read. The payout stays Pending. Retry once. If it persists, contact Nexus support.
Withdrawal not found. (404) get, approve, reject The id is wrong or belongs to another merchant account.
Request IP address is not whitelisted. (403) POST /v1/payout, approve The caller's IP is not on your payout IP whitelist (payout step 1c). Add your server's public egress IP, or check which IP the request is leaving from.