# Accept execution quote
Source: https://docs.stabyl.com/api-reference/exchange/accept-execution-quote
/openapi/openapi.json post /partner/exchange/quotes/{quote_id}/accept
Accepts your quote with trades:create; no body or separate idempotency key is needed. Retry uncertain responses with the same quote ID. Returns 409 for expired unaccepted quotes and 404 for unknown or unowned quotes. Fills may be partial; unfilled amounts are cancelled. Check order_id for the final result.
# Cancel order
Source: https://docs.stabyl.com/api-reference/exchange/cancel-order
/openapi/openapi.json post /partner/exchange/orders/{order_id}/cancel
Requests cancellation of open quantity on an order. Filled quantity is final; only remaining quantity can be cancelled if the order is still cancellable.
Order writes are asynchronous. Store the `Idempotency-Key` before sending the request, reuse it for retries after uncertain responses, and read the order resource for the final state.
# Create execution quote
Source: https://docs.stabyl.com/api-reference/exchange/create-execution-quote
/openapi/openapi.json post /partner/exchange/quotes
Creates a quote valid for 30 seconds with estimated pricing, fees, and a protected price. Requires trades:view. Use expires_at to track expiry. The optional business_bps affects displayed pricing only. Accept the returned quote_id to trade; liquidity is not reserved.
# Create order
Source: https://docs.stabyl.com/api-reference/exchange/create-order
/openapi/openapi.json post /partner/exchange/orders
Creates a limit or market order intent for asynchronous matching. A successful response means the order was accepted for processing; read the order until it reaches a terminal status before treating execution as final.
Order writes are asynchronous. Store the `Idempotency-Key` before sending the request, reuse it for retries after uncertain responses, and read the order resource for the final state.
# Estimate market order (deprecated)
Source: https://docs.stabyl.com/api-reference/exchange/estimate-market-order-deprecated
/openapi/openapi.json post /partner/exchange/orders/estimate
Deprecated: use POST /partner/exchange/quotes. This route remains supported with the same request, response, and trades:view permission.
# Get account trade statistics
Source: https://docs.stabyl.com/api-reference/exchange/get-account-trade-statistics
/openapi/openapi.json get /partner/exchange/trade-statistics
Returns the API-key owner's executed quote volume and marked net-inventory profit/loss for `1d`, `7d`, `30d`, `180d`, or `360d`, with the immediately preceding equal-duration comparison. The report ends at the last completed UTC hour and returns a fixed chart shape. Profit/loss excludes non-trading external contributions valued when committed; posted fees and rebates are already reflected in equity. Historical prices combine executions and Quidax midpoints, preferring executions on timestamp ties; marks must be at most 24 hours old, and unavailable inventory history or valuation returns `503`. Held inventory can change value in buckets without executions. An optional pair filter limits inventory to one current market and fails closed when other-base activity prevents attribution of shared quote inventory; otherwise recognized exchange bases are included and unlike quote cash currencies remain in separate summaries. Responses can be cached for 15 seconds, so use the returned period boundaries.
# Get candles
Source: https://docs.stabyl.com/api-reference/exchange/get-candles
/openapi/openapi.json get /partner/exchange/candles
Returns non-empty OHLCV candles in the exact requested half-open UTC window. `interval` is one candle's width, not the chart range. Supply aligned `from` and `to` boundaries. There is no bucket-count limit; the requested window is never clipped. Results are oldest-first, empty buckets are omitted, and `is_closed` identifies a final candle that can still change. Plot `close` at `timestamp` for a line series. Responses can be cached for five seconds, so use the returned `as_of` as calculation time.
# Get order book levels
Source: https://docs.stabyl.com/api-reference/exchange/get-order-book-levels
/openapi/openapi.json get /partner/exchange/orderbook/{pair_id}/levels
Returns aggregated bid or ask levels for one market. Use this endpoint for focused depth checks when you only need one side of the book.
# Get order book snapshot
Source: https://docs.stabyl.com/api-reference/exchange/get-order-book-snapshot
/openapi/openapi.json get /partner/exchange/orderbook/{pair_id}/snapshot
Returns bid and ask depth for one market, including a sequence value and timestamp. Treat the response as a point-in-time snapshot and refresh before submitting price-sensitive orders.
# Get order details
Source: https://docs.stabyl.com/api-reference/exchange/get-order-details
/openapi/openapi.json get /partner/exchange/orders/{order_id}
Returns the canonical state of one order, including status, quantities, replacement linkage, and fills where available. Use this endpoint after create, cancel, replace, timeout, or retry events.
# Get recent fills
Source: https://docs.stabyl.com/api-reference/exchange/get-recent-fills
/openapi/openapi.json get /partner/exchange/fills
Returns recent public execution entries for a market as a fills view. Use it for execution review and market activity displays; use order detail when reconciling fills for one of your own orders.
# Get recent trades
Source: https://docs.stabyl.com/api-reference/exchange/get-recent-trades
/openapi/openapi.json get /partner/exchange/trades
Returns recent public trades for a market with sequence numbers for incremental reads. Store the last processed sequence if you are building a polling-based market data feed.
# Get ticker statistics
Source: https://docs.stabyl.com/api-reference/exchange/get-ticker-statistics
/openapi/openapi.json get /partner/exchange/ticker/{interval}
Returns rolling statistics for `24h`, `1d`, `7d`, `30d`, `180d`, or `360d`. The `24h` and `1d` paths are aliases. Price and volume fields use the selected window; best bid and best ask always use the existing current order-book lookup and are not historical extrema. Responses can be cached for one second, so use the returned `to` as calculation time.
# List markets
Source: https://docs.stabyl.com/api-reference/exchange/list-markets
/openapi/openapi.json get /partner/exchange/markets
Returns the trading markets currently available to your account. Use this response as the source of truth for pair identifiers, base and quote currencies, price precision, quantity precision, minimum notional, fee basis points, and active trading status before creating orders.
# List orders
Source: https://docs.stabyl.com/api-reference/exchange/list-orders
/openapi/openapi.json get /partner/exchange/orders
Returns your account's orders with optional market, status, and pagination filters. Use this endpoint for dashboards, backfills, and reconciliation jobs.
# Replace order
Source: https://docs.stabyl.com/api-reference/exchange/replace-order
/openapi/openapi.json post /partner/exchange/orders/{order_id}/replace
Submits a replacement intent for an existing limit order. Read the response and the returned order state before assuming the original order is no longer active.
Order writes are asynchronous. Store the `Idempotency-Key` before sending the request, reuse it for retries after uncertain responses, and read the order resource for the final state.
# Simulate fiat deposit
Source: https://docs.stabyl.com/api-reference/simulation/simulate-fiat-deposit
/openapi/openapi.json post /simulate/fiat/deposits
Creates a non-production fiat deposit record and credits the test account for integration testing. This endpoint is disabled in production and cannot move real funds.
Simulation endpoints are available only outside production. Use them to test integration behavior without moving real funds.
# Simulate fiat withdrawal record
Source: https://docs.stabyl.com/api-reference/simulation/simulate-fiat-withdrawal-record
/openapi/openapi.json post /simulate/fiat/withdrawals
Creates a non-production fiat withdrawal record for timeline and reconciliation testing. This endpoint is disabled in production and cannot move real funds.
Simulation endpoints are available only outside production. Use them to test integration behavior without moving real funds.
# Simulate KYB status
Source: https://docs.stabyl.com/api-reference/simulation/simulate-kyb-status
/openapi/openapi.json post /simulate/kyb/status
Sets a test account's KYB status outside production so you can exercise onboarding-gated workflows in staging.
Simulation endpoints are available only outside production. Use them to test integration behavior without moving real funds.
# Simulate product update notification
Source: https://docs.stabyl.com/api-reference/simulation/simulate-product-update-notification
/openapi/openapi.json post /simulate/notifications/product-updates
Creates a non-production product update notification for integration and UI testing. This endpoint is disabled in production.
Simulation endpoints are available only outside production. Use them to test integration behavior without moving real funds.
# Simulate stablecoin deposit
Source: https://docs.stabyl.com/api-reference/simulation/simulate-stablecoin-deposit
/openapi/openapi.json post /simulate/crypto/deposits
Creates a non-production stablecoin deposit record and credits the test account for integration testing. CNGN requires development and chain evm (Base), and credits NGN after the configured deposit fee. This endpoint is disabled in production and cannot move real funds.
Simulation endpoints are available only outside production. Use them to test integration behavior without moving real funds.
# Simulate stablecoin withdrawal record
Source: https://docs.stabyl.com/api-reference/simulation/simulate-stablecoin-withdrawal-record
/openapi/openapi.json post /simulate/crypto/withdrawals
Creates a non-production stablecoin withdrawal record for timeline and reconciliation testing. CNGN requires development and chain evm (Base), and debits NGN principal plus the configured withdrawal fee. This endpoint is disabled in production and cannot move real funds.
Simulation endpoints are available only outside production. Use them to test integration behavior without moving real funds.
# Create saved recipient
Source: https://docs.stabyl.com/api-reference/wallets/create-saved-recipient
/openapi/openapi.json post /partner/wallets/recipients
Creates a saved fiat or crypto recipient record for future use in Stabyl Pro workflows. Production requires approved KYB for fiat recipients. Creating a recipient does not submit a withdrawal or move funds.
Saved recipients are address book records. Creating, updating, or deleting a recipient does not submit a withdrawal or move funds.
# Create withdrawal
Source: https://docs.stabyl.com/api-reference/wallets/create-withdrawal
/openapi/openapi.json post /partner/wallets/withdrawals
Directly creates one parent withdrawal from the signed list request. Production requires approved KYB for every supported NGN and USD rail. Store the returned transaction ID and reconcile status and split-transfer outcomes through wallet transaction reads.
Withdrawal quote and submit calls require an Ed25519 signature over the exact HTTP method, request path and query, timestamp, nonce, and raw body hash. Reuse the business idempotency key with a new timestamp, nonce, and signature when retrying an uncertain response.
# Delete saved recipient
Source: https://docs.stabyl.com/api-reference/wallets/delete-saved-recipient
/openapi/openapi.json delete /partner/wallets/recipients/{recipient_id}
Deletes a saved recipient from the account address book. Existing historical transactions remain available in wallet transaction reads.
Saved recipients are address book records. Creating, updating, or deleting a recipient does not submit a withdrawal or move funds.
# Get credit account summary
Source: https://docs.stabyl.com/api-reference/wallets/get-credit-account-summary
/openapi/openapi.json get /partner/wallets/credit
Returns the credit snapshot for your account, including available credit, credit limit, credit used, outstanding debt, account status and repayment due date. This is the same credit snapshot included in the wallet balance overview.
# Get credit repayment status
Source: https://docs.stabyl.com/api-reference/wallets/get-credit-repayment-status
/openapi/openapi.json get /partner/wallets/credit/repayments/{repayment_id}
Read the status and repaid amount of your repayment request. A completed receipt may report zero when no eligible debt or available funds remain, or the amount is too small to apply. A missing receipt is not confirmation of payment.
# Get crypto deposit address
Source: https://docs.stabyl.com/api-reference/wallets/get-crypto-deposit-address
/openapi/openapi.json get /partner/wallets/crypto/address
Returns the deposit address for a requested chain and creates it when it does not already exist. Production requires approved KYB before existing details are returned or a new address is provisioned. Store the chain and address together, and display the returned route exactly.
Show the returned chain and address exactly. Sending funds on a different chain can cause permanent loss.
# Get crypto deposit Travel Rule requirements
Source: https://docs.stabyl.com/api-reference/wallets/get-crypto-deposit-travel-rule-requirements
/openapi/openapi.json get /partner/wallets/crypto/deposits/{id}/travel-rule
Returns the sender information fields required to release a held crypto deposit. Use the transaction ID from wallet transaction reads.
Use these endpoints only when a wallet transaction includes `required_action.type = "travel_rule"`. The deposit remains unavailable until clearance completes.
# Get fiat deposit account
Source: https://docs.stabyl.com/api-reference/wallets/get-fiat-deposit-account
/openapi/openapi.json get /partner/wallets/fiat/deposit-account
Returns the fiat deposit account for incoming NGN deposits. Production requires approved KYB before existing details are returned or a new account is provisioned. Display account name, bank name, and account number exactly as returned, then reconcile deposits through wallet transactions.
Fiat deposit and account-verification routes prepare account records only. Reconcile credited deposits from wallet transactions.
# Get wallet balance overview
Source: https://docs.stabyl.com/api-reference/wallets/get-wallet-balance-overview
/openapi/openapi.json get /partner/wallets/balances/overview
Returns current NGN and USD balances, including available and locked amounts. Use available balance for spend checks and locked balance to explain funds committed to pending activity.
Use `available` for spend checks and `locked` to explain funds reserved for pending account activity. Use `withdrawable` for the amount that can be withdrawn right now; it accounts for outstanding credit and may be null when temporarily unavailable.
# Get wallet transaction
Source: https://docs.stabyl.com/api-reference/wallets/get-wallet-transaction
/openapi/openapi.json get /partner/wallets/transactions/{id}
Returns detailed information for one wallet transaction by public transaction ID, including settlement details when available. For an NGN withdrawal, `settlement_details.transfers` lists every bank transfer with its own status, `client_item_id`, and recipient, which is how you reconcile bulk withdrawals and `partial` outcomes. Use this endpoint to refresh known records or investigate reconciliation gaps.
Use wallet transactions as the reconciliation source of truth. Withdrawals initiated through the API or Stabyl Pro can appear in this timeline. A bulk NGN withdrawal is one parent transaction; read its detail and walk `settlement_details.transfers` to see the outcome of each bank transfer.
# List credit activity
Source: https://docs.stabyl.com/api-reference/wallets/list-credit-activity
/openapi/openapi.json get /partner/wallets/credit/activity
Returns a cursor-paginated history of credit draws and repayments for your account. Trade activity is grouped by order. Pass the returned next_cursor as cursor to read the next page; a null next_cursor marks the end of the list.
# List outstanding credit commitments
Source: https://docs.stabyl.com/api-reference/wallets/list-outstanding-credit-commitments
/openapi/openapi.json get /partner/wallets/credit/outstanding
Returns a cursor-paginated list of credit reserved for open orders and unpaid drawn credit grouped by order. Each item identifies its status, remaining outstanding amount and repayment due date when applicable. Pass next_cursor as cursor to continue.
# List saved recipients
Source: https://docs.stabyl.com/api-reference/wallets/list-saved-recipients
/openapi/openapi.json get /partner/wallets/recipients
Returns saved recipient records with optional type and currency filters. Use this endpoint to keep internal tools aligned with the current account address book.
Saved recipients are address book records. Creating, updating, or deleting a recipient does not submit a withdrawal or move funds.
# List supported banks
Source: https://docs.stabyl.com/api-reference/wallets/list-supported-banks
/openapi/openapi.json get /partner/wallets/fiat/banks
Returns supported Nigerian banks for fiat recipient setup and account verification. Refresh this list periodically instead of maintaining a hardcoded bank directory.
Fiat deposit and account-verification routes prepare account records only. Reconcile credited deposits from wallet transactions.
# List supported wallet chains
Source: https://docs.stabyl.com/api-reference/wallets/list-supported-wallet-chains
/openapi/openapi.json get /partner/wallets/chains
Returns settlement routes, network metadata and availability. Defaults to USD; currency=NGN includes bank cash and CNGN on Base. Anonymous requests show global cNGN fees and deposit minimum; a valid session cookie shows the user's effective values. Invalid sessions return 401. API-key headers alone do not personalize this public endpoint. Responses are private and not cached.
Use this route list as the source of truth before showing deposit instructions. Chain availability can differ by environment.
# List wallet currencies
Source: https://docs.stabyl.com/api-reference/wallets/list-wallet-currencies
/openapi/openapi.json get /partner/wallets/currencies
Returns user-facing wallet currencies. Stabyl exposes NGN and USD wallets; USDT and USDC are stablecoin settlement rails that credit the USD wallet.
`USDT` and `USDC` are stablecoin settlement rails for the USD wallet. They are not returned as separate wallet currencies.
# List wallet transactions
Source: https://docs.stabyl.com/api-reference/wallets/list-wallet-transactions
/openapi/openapi.json get /partner/wallets/transactions
Returns a cursor-paginated wallet timeline across fiat and stablecoin activity. Use this endpoint for reconciliation of deposits, fees, adjustments, refunds, and withdrawals initiated through the API or Stabyl Pro. A withdrawal to several beneficiaries is one timeline item; its `transfer_count` reports how many bank transfers it created.
Use wallet transactions as the reconciliation source of truth. Withdrawals initiated through the API or Stabyl Pro can appear in this timeline. A bulk NGN withdrawal is one parent transaction; read its detail and walk `settlement_details.transfers` to see the outcome of each bank transfer.
# Quote withdrawals
Source: https://docs.stabyl.com/api-reference/wallets/quote-withdrawals
/openapi/openapi.json post /partner/wallets/withdrawal/quotes
Validates a list-shaped NGN bank or one-item USD stablecoin withdrawal and returns aggregate debit, fee, expected receive amount, item previews, and transfer count. Quoting never reserves funds.
Withdrawal quote and submit calls require an Ed25519 signature over the exact HTTP method, request path and query, timestamp, nonce, and raw body hash. Reuse the business idempotency key with a new timestamp, nonce, and signature when retrying an uncertain response.
# Repay outstanding credit
Source: https://docs.stabyl.com/api-reference/wallets/repay-outstanding-credit
/openapi/openapi.json post /partner/wallets/credit/repayments
Request repayment of outstanding USD or NGN credit from available funds in the same currency. The amount is a maximum. Retain the original Idempotency-Key when retrying and poll the receipt until completed; processing does not confirm payment.
# Submit crypto deposit Travel Rule information
Source: https://docs.stabyl.com/api-reference/wallets/submit-crypto-deposit-travel-rule-information
/openapi/openapi.json post /partner/wallets/crypto/deposits/{id}/travel-rule
Submits sender information for a held crypto deposit. Repeating a submission for an already submitted case returns the submitted state.
Use these endpoints only when a wallet transaction includes `required_action.type = "travel_rule"`. The deposit remains unavailable until clearance completes.
# Update saved recipient
Source: https://docs.stabyl.com/api-reference/wallets/update-saved-recipient
/openapi/openapi.json patch /partner/wallets/recipients/{recipient_id}
Updates mutable recipient fields. Production requires approved KYB for every fiat-recipient update; bank detail changes also require account verification again. Updating a recipient does not submit a withdrawal or move funds.
Saved recipients are address book records. Creating, updating, or deleting a recipient does not submit a withdrawal or move funds.
# Verify bank account
Source: https://docs.stabyl.com/api-reference/wallets/verify-bank-account
/openapi/openapi.json post /partner/wallets/fiat/verify-account
Verifies fiat bank account details and returns the account name that should be shown before saving a recipient. Production requires approved KYB. Verification does not create a recipient and does not move funds.
Fiat deposit and account-verification routes prepare account records only. Reconcile credited deposits from wallet transactions.
# Create a Partner webhook endpoint
Source: https://docs.stabyl.com/api-reference/webhooks/create-a-partner-webhook-endpoint
/openapi/openapi.json post /partner/webhooks
Creates an owned HTTPS endpoint and returns its Standard Webhooks signing secret exactly once. Requires `webhooks:manage` and the matching `wallets:view` and/or `trades:view` scope for every subscribed event type.
# Delete a Partner webhook endpoint
Source: https://docs.stabyl.com/api-reference/webhooks/delete-a-partner-webhook-endpoint
/openapi/openapi.json delete /partner/webhooks/{webhook_id}
Soft-deletes an owned endpoint and releases its active slot. Repeating the delete for the same owned identifier is idempotent. An already in-flight HTTP request cannot be recalled.
# Fully replace a Partner webhook endpoint
Source: https://docs.stabyl.com/api-reference/webhooks/fully-replace-a-partner-webhook-endpoint
/openapi/openapi.json put /partner/webhooks/{webhook_id}
Fully replaces URL, nullable description, enabled state, and exact subscription set atomically. Replacing an endpoint never rotates its signing secret.
# Get a Partner webhook endpoint
Source: https://docs.stabyl.com/api-reference/webhooks/get-a-partner-webhook-endpoint
/openapi/openapi.json get /partner/webhooks/{webhook_id}
Returns one owned endpoint. Unknown and cross-account identifiers both return 404. No signing material is returned.
# Get a projected Partner webhook delivery
Source: https://docs.stabyl.com/api-reference/webhooks/get-a-projected-partner-webhook-delivery
/openapi/openapi.json get /partner/webhook/deliveries/{delivery_id}
Returns one owned delivery and completed logical attempts in ascending attempt order. Attempts expose only signing secret version numbers, never secrets or signature values. Unknown, cross-account, and domain-inaccessible identifiers return 404.
# Get a projected Partner webhook event
Source: https://docs.stabyl.com/api-reference/webhooks/get-a-projected-partner-webhook-event
/openapi/openapi.json get /partner/webhook/events/{event_id}
Returns the exact public envelope and its eventually consistent delivery summaries. Unknown, cross-account, and domain-inaccessible identifiers all return 404.
# List Partner webhook endpoints
Source: https://docs.stabyl.com/api-reference/webhooks/list-partner-webhook-endpoints
/openapi/openapi.json get /partner/webhooks
Lists enabled and disabled owned endpoints using a newest-first opaque cursor. Signing secrets and encrypted material are never returned.
# List projected Partner webhook deliveries
Source: https://docs.stabyl.com/api-reference/webhooks/list-projected-partner-webhook-deliveries
/openapi/openapi.json get /partner/webhook/deliveries
Lists slim delivery history in `created_at DESC, id DESC` order. History and `next_attempt_at` are eventual, display-only projections and are never delivery acknowledgements or scheduling state.
# List projected Partner webhook events
Source: https://docs.stabyl.com/api-reference/webhooks/list-projected-partner-webhook-events
/openapi/openapi.json get /partner/webhook/events
Lists slim event history in `occurred_at DESC, id DESC` order. History is eventually consistent with live delivery. Unfiltered results include only wallet and/or exchange domains allowed by the caller's read scopes.
# Rotate a Partner webhook signing secret
Source: https://docs.stabyl.com/api-reference/webhooks/rotate-a-partner-webhook-signing-secret
/openapi/openapi.json post /partner/webhooks/{webhook_id}/secret/rotate
Generates and returns a new Standard Webhooks secret exactly once. The previous secret remains valid only for the configured overlap; a second rotation during overlap returns 409.
# Changelog
Source: https://docs.stabyl.com/changelog/overview
Product updates and improvements to the Stabyl API.
Follow along for new features, improvements, and fixes shipped to the Stabyl API.
## New features
**`withdrawal.partial` webhook.** A multi-beneficiary NGN withdrawal that finishes with some bank transfers paid and the rest failed now emits `withdrawal.partial`, alongside the existing `withdrawal.success` and `withdrawal.failed` events. Previously a `partial` outcome produced no webhook. The new topic is additive: existing subscriptions are unchanged, and you opt in by adding `withdrawal.partial` to an endpoint's `event_types`. See [Withdrawal webhook events](/docs/webhook-withdrawal-events).
## Fixes
* **Travel Rule `submission_url` in webhooks.** The `deposit.action_required` event now carries the same `/partner/wallets/...` path as transaction reads, so it can be called directly with an API key. A deposit that was already on hold before this change may receive one additional `deposit.action_required` event, with a new event `id`, carrying the corrected URL.
## Documentation
**Bulk withdrawals documented.** The Partner API has always accepted up to 50 bank-account beneficiaries in one signed NGN withdrawal request. A new [Bulk withdrawals](/docs/bulk-withdrawals) guide now spells out the item and transfer limits, the ₦100 per-transfer fee, the `partial` outcome and its refund behaviour, and how to reconcile every transfer by `client_item_id`. The API reference now also declares the 1 to 50 item range on the quote and submit bodies. No API behaviour changed.
## New features
**Signed direct partner withdrawals.** Dedicated API keys can explicitly enable `wallets:withdraw` and use Ed25519-signed quote and direct-submit requests. NGN amounts above the per-transfer limit are represented as grouped transfers in quote, submission, and transaction responses. The same list envelope supports one-item USD stablecoin settlement. See [Withdrawals](/docs/withdrawals).
## Compatibility
Public withdrawal requests now use one list-shaped body for both quote and submit. Transport retries keep the same business idempotency key and body while generating a new timestamp, nonce, and signature.
## New features
**Trade fill email notifications.** Account holders now receive email notifications when their exchange orders fill, bucketed to avoid noisy delivery on partial fills. No API changes are required — emails are sent automatically to the account's verified address.
**Email OTP gate for passkey enrollment.** Adding a new passkey to an account now requires a one-time code delivered by email before enrollment can complete. This protects accounts from passkey takeover even if a session is compromised. See [Authentication](/docs/authentication) for the supported credential flows.
## Updates
**KYB verification upgraded.** Business onboarding now runs on a new verification flow. The KYB statuses returned by the API and the [sandbox `POST /simulate/kyb/status` endpoint](/docs/sandbox-simulation) are unchanged, so integrations that read KYB state continue to work without modification.
**Partner API key setup links hardened.** The one-time setup links partners use to provision API keys are now more resilient to reuse and edge cases during onboarding, making the flow described in the [Quickstart](/docs/quickstart) more reliable.
**Exchange routing hardened.** An internal configuration option that could affect order routing was removed, so routing behaviour is now consistent across deployments. No action is required from API consumers.
## Bug fixes
* **Order admission near full balance.** Orders that would consume nearly the entire available balance are now admitted correctly instead of being rejected by a rounding edge case. See [Order management](/docs/order-management).
* **Passkey revoke listing.** Revoked passkeys are now filtered out of the active passkey listing so the credentials shown match what can actually be used to sign in.
* **TRON deposits and withdrawals.** Several fixes to TRON network handling make TRON [deposits](/docs/crypto-deposit-addresses) and [withdrawals](/docs/withdrawals) more reliable under load.
* **Wallet notifications.** Wallet event notifications are more consistent after an internal configuration cleanup, reducing the chance of missed or delayed wallet events.
* **Exchange restart recovery.** Recovery after an exchange restart is more robust, eliminating rare cases where a restart could delay order processing.
## New features
**WebSocket ticket authentication.** You can now authenticate private WebSocket subscriptions and order commands with short-lived tickets instead of passing your API key in the connection URL. This keeps API keys off the wire and makes it safer to fan out streaming connections from constrained environments. See [WebSocket Overview](/docs/websocket-overview) for the supported authentication flows.
**Partner API and API key onboarding.** Partner accounts can now provision API keys through a guided setup flow, with reference documentation for the partner endpoints. Start with the [Quickstart](/docs/quickstart) and [Authentication](/docs/authentication) guides.
**Upgraded crypto settlement infrastructure.** Crypto deposits and withdrawals now run on upgraded settlement infrastructure across supported chains. Existing [crypto deposit addresses](/docs/crypto-deposit-addresses) and [withdrawal](/docs/withdrawals) flows are unchanged for API consumers — addresses, networks, and confirmations continue to behave as documented.
## Updates
**Exchange fill notifications.** Fill notifications are now bucketed for delivery and no longer include system-internal recipients, producing cleaner and more predictable webhook traffic for trading clients.
## Bug fixes
* **Crypto settlement event parsing** is more resilient to edge-case payloads, reducing the chance of dropped deposit or withdrawal events.
* **Asset identifiers in crypto settlement events** are now canonicalized so deposit and withdrawal events consistently match the asset IDs returned by the API.
* **Duplicate exchange fill notifications** are now suppressed end-to-end through stronger idempotency, so each fill is delivered exactly once.
# Authentication
Source: https://docs.stabyl.com/docs/authentication
Authenticate API requests and operate keys safely.
Authenticated routes require an API key in the `X-Api-Key` header:
```http theme={null}
X-Api-Key:
```
## Create a staging API key
Sign in to the [Stabyl staging app](https://staging-app.stabyl.com) to create
API keys for staging. Store the generated key when it is shown and use it only
with `https://api-staging.stabyl.com/v1`.
Keep API keys in server-side systems only. Do not embed keys in browser, mobile, desktop, or user-controlled code.
## Production API access
Production API access is not enabled by staging access. Your organization must
complete KYB and receive an explicit invitation from Stabyl before provisioning
production credentials. Follow the instructions in that invitation and use the
resulting credentials only with `https://api.stabyl.com/v1`.
Do not copy a staging key into production configuration or treat successful
staging integration as production approval.
New keys use immutable scopes. The default scope set is `trades:view`,
`trades:create`, `trades:execute`, `wallets:view`, `wallets:transfer`, and
`settlements:view`. It excludes `webhooks:view`, `webhooks:manage`, and
`wallets:withdraw`, so select those explicitly during key creation when you
need them. Selecting `wallets:withdraw` also requires completing account
authentication with an existing account passkey or withdrawal password.
Changing scopes requires replacing the key. An IP or CIDR allowlist is
optional.
A key can never do more than its stored scopes allow. It can do less if your
account's own permissions are later reduced, because effective access is the
intersection of the two.
## Key Handling Rules
| Rule | Reason |
| - | - |
| Store keys in a secret manager or encrypted environment store | Keys authorize account activity |
| Use separate keys per service | Limits blast radius and simplifies rotation |
| Do not log full key values | Logs are copied and retained in many systems |
| Keep staging and production keys separate | Prevents accidental live requests |
| Use a dedicated key for withdrawals | Keeps signing material and scope narrow |
| Store the signing secret returned at creation | The secret is displayed only once |
## Signed Withdrawal Requests
Withdrawal quote and submit calls require three additional headers:
```http theme={null}
X-Stabyl-Timestamp:
X-Stabyl-Nonce:
X-Stabyl-Signature:
```
The signature covers the HTTP method, exact path and query, timestamp, nonce, and SHA-256 hash of the exact raw body bytes. See [Withdrawals](/docs/withdrawals) for canonicalization rules and the fixed test vector.
The signed request is the Partner withdrawal authorization. Quote and submit do
not accept or wait for an emailed verification code or client-supplied
withdrawal token.
## Public Endpoints
Only these Mintlify-documented routes do not require an API key:
* `GET /partner/wallets/chains`
* `GET /partner/wallets/fiat/banks`
* `POST /simulate/fiat/deposits`
* `POST /simulate/fiat/withdrawals`
* `POST /simulate/crypto/deposits`
* `POST /simulate/crypto/withdrawals`
* `POST /simulate/kyb/status`
* `POST /simulate/notifications/product-updates`
All other documented routes require `X-Api-Key`.
## Authentication Failures
| Status | What it usually means | What to check |
| - | - | - |
| `401` | The key or required signature is missing, malformed, invalid, or sent to the wrong environment | Header values, canonical path, raw body bytes, clock, key value, and base URL |
| `403` | The key is valid but lacks the required scope, is not a partner API key, fails its IP allowlist, or the account has not met a production requirement such as KYB | Key scopes, account readiness, and the caller's source IP. Scope failures return code `FORBIDDEN` on ordinary routes and `MISSING_SCOPE` on signed withdrawal routes |
| `409` | A signed nonce was replayed, or a withdrawal idempotency key was reused for another intent | Generate a new nonce for transport retries; keep the body unchanged |
| `429` | Requests are arriving too quickly | Backoff and reduce polling or retry loops |
| `503` | Signed replay protection, the rate-limit store, or webhook management is temporarily unavailable | Fail closed and retry later with a new nonce |
If the same key works for reads but not withdrawal writes, confirm `wallets:withdraw` is present and that the request was signed with the secret returned for that key.
# Bulk Withdrawals
Source: https://docs.stabyl.com/docs/bulk-withdrawals
Pay many beneficiaries from one signed request and reconcile each transfer.
A bulk NGN withdrawal pays several bank accounts from one signed request. You
send one list of beneficiaries, Stabyl creates one parent withdrawal, and every
beneficiary settles as its own bank transfer. This is the right tool for
payroll, supplier runs, customer refunds, and any other job where you would
otherwise loop over single withdrawals.
Bulk uses the same routes, signing, and scopes as a single withdrawal. If you
have not read [Single withdrawals](/docs/withdrawals) yet, start there. This
page only covers what changes when `items` has more than one entry.
Bulk withdrawals are available for NGN bank payouts today. A USD stablecoin
withdrawal accepts exactly one item per request; sending more returns
`400 VALIDATION_FAILED`.
## How it works
1. Build one `items` list with one entry per beneficiary.
2. Quote it with `POST /partner/wallets/withdrawal/quotes` to preview the total
debit and the number of bank transfers.
3. Submit the same list with `POST /partner/wallets/withdrawals` and one
`idempotency_key`.
4. Persist the returned parent `id`, then reconcile each transfer with
`GET /partner/wallets/transactions/{id}`.
The whole request is checked before any money moves. If one beneficiary is
invalid, the request is rejected and nothing is debited. Once accepted, every
transfer settles on its own, so one bank rejecting a payment does not undo the
others.
## Limits
| Limit | Value | What happens when you exceed it |
| - | - | - |
| Beneficiaries per request | 1 to 50 items | `400 VALIDATION_FAILED`, nothing is created |
| Largest single bank transfer | ₦100,000,000.00 | The item is split into several transfers automatically |
| Bank transfers per request | 500 in total | `400 VALIDATION_FAILED`, nothing is created |
| Smallest item amount | ₦1.00 | `400 VALIDATION_FAILED` below ₦1.00. Kobo is dropped silently: ₦350,000.50 pays ₦350,000.00 |
| Total principal per request | Your account policy | `422 ACCOUNT_WITHDRAWAL_LIMIT_EXCEEDED`, nothing is created |
| Available balance | Total principal plus fees | `402 INSUFFICIENT_BALANCE`, nothing is created |
The account limit and balance check apply to the whole request, not to each
beneficiary. A request of 50 small payments and a request of one large payment
are both measured by their total. The account limit counts principal only; the
balance check counts principal plus the ₦100 fee per transfer. The item,
transfer, and per-transfer amount limits are server settings that can change,
so treat them as current values rather than permanent constants. Because kobo
is dropped after the request is accepted, send whole-naira amounts; two
requests that differ only in kobo are different intents under the same
idempotency key.
## Fees
The NGN withdrawal fee is a flat ₦100.00 for every bank transfer that is
created. Most beneficiaries need exactly one transfer, so for a typical bulk
request the fee is ₦100.00 multiplied by the number of beneficiaries. A
beneficiary above ₦100,000,000.00 is split into more than one transfer and pays
the fee for each of them.
The quote response reports `transfer_count`, the aggregate `fee`, and the
`debit_amount` that will leave your NGN wallet, so you can show the true cost
before you submit.
## Quote a bulk request
Quote bodies use `items` and omit `idempotency_key`:
```json theme={null}
{
"currency": "NGN",
"items": [
{
"client_item_id": "payroll-2026-09-emp-0412",
"amount": "350000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Adaeze Okafor"
},
"description": "September salary"
},
{
"client_item_id": "payroll-2026-09-emp-0587",
"amount": "420000.00",
"destination": {
"type": "bank_account",
"bank_code": "058",
"bank_name": "GTBank",
"account_number": "0987654321",
"account_name": "Tunde Bakare"
},
"description": "September salary"
},
{
"client_item_id": "payroll-2026-09-emp-0731",
"amount": "275000.00",
"destination": {
"type": "bank_account",
"bank_code": "057",
"bank_name": "Zenith Bank",
"account_number": "1122334455",
"account_name": "Ngozi Eze"
},
"description": "September salary"
}
]
}
```
The quote for this list reports three transfers, a fee of `300.00`, an amount
of `1045000.00`, and a `debit_amount` of `1045300.00`. Quoting never reserves
funds, but it runs the account-limit and balance checks and has its own rate
limit, so quote when the operator reviews the list rather than on every
keystroke.
## Submit a bulk request
Submit uses the same list and adds one `idempotency_key` for the whole batch.
Sign the exact bytes you send, as described in
[Request Signing](/docs/withdrawals#request-signing).
```json theme={null}
{
"currency": "NGN",
"items": [
{
"client_item_id": "payroll-2026-09-emp-0412",
"amount": "350000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Adaeze Okafor"
},
"description": "September salary"
},
{
"client_item_id": "payroll-2026-09-emp-0587",
"amount": "420000.00",
"destination": {
"type": "bank_account",
"bank_code": "058",
"bank_name": "GTBank",
"account_number": "0987654321",
"account_name": "Tunde Bakare"
},
"description": "September salary"
},
{
"client_item_id": "payroll-2026-09-emp-0731",
"amount": "275000.00",
"destination": {
"type": "bank_account",
"bank_code": "057",
"bank_name": "Zenith Bank",
"account_number": "1122334455",
"account_name": "Ngozi Eze"
},
"description": "September salary"
}
],
"idempotency_key": "payroll-2026-09-run-1"
}
```
Use one `idempotency_key` per batch, not per beneficiary. Give every item a
`client_item_id` from your own system. It is optional, but it is the easiest
way to match a transfer outcome back to the right employee, supplier, or
customer, and it comes back in the submit response and in every later
transaction read.
The same bank account can appear more than once in a list. Each entry is paid
separately, so check your list for accidental duplicates before you submit.
## Submit response
The response is one parent transaction. `items` keeps the order of your
request, and each item lists the bank transfers that will pay it.
```json theme={null}
{
"status": "success",
"data": {
"id": "0198f3a0-0000-7000-8000-0000000000a1",
"status": "pending",
"rail": "fiat",
"currency": "NGN",
"tx_type": "withdrawal",
"amount": "1045000.00",
"reference": "WD-0198f3a0-1c2d-7e4f-8a91-0b3c5d7e9f11",
"fee": "300.00",
"debit_amount": "1045300.00",
"estimated_receive": "1045000.00",
"receive_currency": "NGN",
"transfer_count": 3,
"max_transfer_amount": "100000000.00",
"items": [
{
"client_item_id": "payroll-2026-09-emp-0412",
"amount": "350000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Adaeze Okafor"
},
"transfers": [{ "sequence": 1, "amount": "350000.00", "status": "processing" }]
},
{
"client_item_id": "payroll-2026-09-emp-0587",
"amount": "420000.00",
"destination": {
"type": "bank_account",
"bank_code": "058",
"bank_name": "GTBank",
"account_number": "0987654321",
"account_name": "Tunde Bakare"
},
"transfers": [{ "sequence": 2, "amount": "420000.00", "status": "processing" }]
},
{
"client_item_id": "payroll-2026-09-emp-0731",
"amount": "275000.00",
"destination": {
"type": "bank_account",
"bank_code": "057",
"bank_name": "Zenith Bank",
"account_number": "1122334455",
"account_name": "Ngozi Eze"
},
"transfers": [{ "sequence": 3, "amount": "275000.00", "status": "processing" }]
}
]
}
}
```
`sequence` numbers run across the whole batch, so the first transfer of the
second item continues counting from where the first item stopped. The parent
starts as `pending` and each transfer as `processing`. Store `data.id`
immediately. It is the only identifier you need to follow the batch to
completion.
## Outcomes
Each bank transfer succeeds or fails on its own. The parent status summarizes
them:
| Parent status | Meaning |
| - | - |
| `pending` | Accepted; the batch has not been handed to the bank yet |
| `processing` | At least one transfer is still with the bank |
| `success` | Every transfer was paid |
| `partial` | Some transfers were paid and the rest failed. This is final. |
| `failed` | No transfer was paid |
| `cancelled` | The batch was cancelled before any transfer was paid. This is final. |
When a transfer fails, its principal and its ₦100.00 fee are returned to your
NGN wallet. Transfers that succeeded stay paid. You never need to reverse or
re-send the successful part of a batch; only re-send the beneficiaries whose
transfers failed, in a new request with a new `idempotency_key`.
Each outcome has its own webhook: `withdrawal.success`, `withdrawal.partial`,
and `withdrawal.failed`. Subscribe to all three so a mixed batch is not left
waiting for an event that will never arrive. See
[Withdrawal webhook events](/docs/webhook-withdrawal-events) for the payloads.
A webhook tells you the batch has finished. It does not list who was paid.
Finish bulk reconciliation with the transaction read below, which carries the
per-transfer outcomes.
## Reconciling each transfer
Read the parent with `GET /partner/wallets/transactions/{id}`. The
`settlement_details.transfers` list carries one entry per bank transfer with
its own `status`, your `client_item_id`, and the position of the item in your
original request.
```json theme={null}
{
"status": "success",
"data": {
"id": "0198f3a0-0000-7000-8000-0000000000a1",
"rail": "fiat",
"currency": "NGN",
"tx_type": "withdrawal",
"status": "partial",
"amount": "1045000.00",
"reference": "WD-0198f3a0-1c2d-7e4f-8a91-0b3c5d7e9f11",
"description": "September salary",
"failure_reason": null,
"created_at": "2026-09-09T08:12:04+00:00",
"updated_at": "2026-09-09T08:19:41+00:00",
"settlement_details": {
"type": "bank_transfer",
"rail_reference": "WD-0198f3a0-1c2d-7e4f-8a91-0b3c5d7e9f11",
"recipient": {
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Adaeze Okafor"
},
"transfer_count": 3,
"transfers": [
{
"sequence": 1,
"item_index": 0,
"item_transfer_index": 1,
"client_item_id": "payroll-2026-09-emp-0412",
"amount": "350000.00",
"status": "success",
"description": "September salary",
"recipient": {
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Adaeze Okafor"
}
},
{
"sequence": 2,
"item_index": 1,
"item_transfer_index": 1,
"client_item_id": "payroll-2026-09-emp-0587",
"amount": "420000.00",
"status": "failed",
"description": "September salary",
"recipient": {
"bank_code": "058",
"bank_name": "GTBank",
"account_number": "0987654321",
"account_name": "Tunde Bakare"
}
},
{
"sequence": 3,
"item_index": 2,
"item_transfer_index": 1,
"client_item_id": "payroll-2026-09-emp-0731",
"amount": "275000.00",
"status": "success",
"description": "September salary",
"recipient": {
"bank_code": "057",
"bank_name": "Zenith Bank",
"account_number": "1122334455",
"account_name": "Ngozi Eze"
}
}
],
"confirmed_at": "2026-09-09T08:19:41+00:00",
"failed_at": null
}
}
}
```
| Field | Meaning |
| - | - |
| `sequence` | Position of the transfer across the whole batch, starting at 1 |
| `item_index` | Position of the beneficiary in your `items` list, starting at 0 |
| `item_transfer_index` | Position of the transfer within that beneficiary, starting at 1 |
| `client_item_id` | The value you sent for that beneficiary; omitted if you sent none |
| `status` | `pending`, `processing`, `success`, `failed`, or `cancelled` for this transfer alone |
The parent-level `recipient` and `description` show the first beneficiary in
the batch. Use the per-transfer `recipient` and `client_item_id` for anything
beneficiary specific. `description` and `recipient` on a transfer are omitted
rather than `null` when absent. On a `partial` parent, `confirmed_at` is the
time the batch finished settling and `failed_at` stays `null`.
`settlement_details.rail_reference` carries the same `WD-` reference as
`reference`.
In the example above, two employees were paid and one transfer failed. Your
system should mark the first and third employees as paid and queue a fresh
withdrawal for the second employee after checking the account details. The
wallet receives `420100.00` back in total: a `refund` transaction for the
`420000.00` principal appears in the timeline with the description
`Refund for failed withdrawal transfers`, and the `100.00` fee for that
transfer is released back to the balance alongside it.
## Retries and idempotency
A transport failure on submit is handled exactly like a single withdrawal.
Keep the same body and `idempotency_key`, generate a new timestamp and nonce,
sign again, and resubmit. The same key with the same list returns the existing
parent instead of paying anyone twice.
Changing anything in the list under the same key returns
`IDEMPOTENCY_CONFLICT`. That includes the order of the items, an amount, an
account number, a description, or a `client_item_id`. If your list changed,
it is a new batch and needs a new key.
## Practical tips
* Quote first and show the `transfer_count` and `debit_amount` to the person
approving the batch.
* Keep batches at 50 beneficiaries or fewer and split larger payroll runs into
several requests with distinct idempotency keys such as
`payroll-2026-09-run-1`, `payroll-2026-09-run-2`.
* Save `client_item_id` alongside your own record before you submit, so a
restart between submit and reconciliation cannot lose the mapping.
* Treat `partial` as final. Retry only the failed beneficiaries, never the
whole batch.
* Subscribe to `withdrawal.partial` alongside `withdrawal.success` and
`withdrawal.failed`, or a mixed batch will never trigger your webhook
handler.
* Reconcile every batch with a transaction read even when a webhook arrived.
Webhooks tell you the batch finished; the transaction read tells you who was
paid.
# Crypto Deposit Addresses
Source: https://docs.stabyl.com/docs/crypto-deposit-addresses
Requesting deposit addresses for supported settlement rails and chains.
Request one deposit address for the chain you selected. The API creates or returns the address for your Stabyl account on that route. Addresses are requested on demand, so you do not need to create addresses across every supported chain before the user chooses a route.
Production requires approved KYB before this endpoint returns an existing
address or provisions a new one. A non-approved account receives `403`, even if
an address was provisioned previously. Development and staging skip this gate.
`chain` is the route family (`evm`, `tron`, or `solana`) from
`GET /partner/wallets/chains`. When the family has more than one network, as
`evm` does in production, also pass the exact network as `slug`; omitting it
returns `400`.
```bash theme={null}
curl "https://api-staging.stabyl.com/v1/partner/wallets/crypto/address?chain=tron" \
-H "X-Api-Key: $STABYL_API_KEY"
curl "https://api.stabyl.com/v1/partner/wallets/crypto/address?chain=evm&slug=base" \
-H "X-Api-Key: $STABYL_API_KEY"
```
## Response Handling
Store `chain`, `slug`, `address`, and `memo` together. When displaying deposit
instructions, show the exact network next to the address. If your interface
has QR codes, encode only the returned address and keep the network label
visible outside the QR code.
```json theme={null}
{
"status": "success",
"data": {
"chain": "tron",
"slug": "tron",
"address": "TXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"memo": null
}
}
```
| Field | Meaning |
| - | - |
| `chain` | Route family the address belongs to |
| `slug` | Exact network, or `null` when the family has a single network |
| `address` | Deposit address to show |
| `memo` | Memo or tag that must accompany the transfer when not `null`. Omitting a required memo can lose funds |
## Safety Rules
* Use the `network` and `chain.slug` values returned by `GET /partner/wallets/chains`.
* Show the exact returned network, address, and memo.
* Sending funds on a different network can permanently lose funds.
* Do not rewrite, trim, or normalize crypto addresses beyond basic whitespace handling.
* Save the network alongside every address, deposit record, and support ticket reference.
Crypto deposits credit the USD wallet after confirmation and any required
compliance checks. `USDT` and `USDC` are stablecoin rails for USD activity.
If a deposit needs Travel Rule information, wallet transaction reads return
`status: "hold"` with `required_action.type: "travel_rule"`. Submit the sender
details through `POST /partner/wallets/crypto/deposits/{id}/travel-rule`.
`required_action.status` then changes to `submitted` and stays present, with
the deposit still on `hold`, until clearance completes and the wallet is
credited.
## Deposit States
| State | Meaning |
| - | - |
| pending | Deposit has been detected or is waiting for confirmation |
| processing | Deposit is progressing and is not yet available |
| hold | Deposit is confirmed but waiting for compliance clearance or required user information |
| success | Deposit has been credited and is available |
| failed | Deposit could not be credited |
| cancelled | Deposit was cancelled before it could be credited |
Use deposit webhooks for timely changes and wallet transaction reads for
periodic reconciliation and webhook-gap recovery.
Common failures are `400` for an unsupported `chain` or a missing `slug`, `401` for a missing `X-Api-Key`, `403` for a key without `wallets:view` or an account whose production KYB is not approved, and `503` when no route on that family currently accepts new deposit addresses.
# Environments
Source: https://docs.stabyl.com/docs/environments
Production, staging, request conventions, and simulation behavior.
Stabyl exposes separate production and staging base URLs. API keys are environment-specific.
Create staging API keys in the
[Stabyl staging app](https://staging-app.stabyl.com). Use those keys only with
the staging API base URL.
Production API access requires completed KYB and an explicit invitation from
Stabyl. Staging access alone does not authorize production use. The invitation
contains the instructions for provisioning production credentials.
| Environment | Base URL | Purpose |
| - | - | - |
| Production | `https://api.stabyl.com/v1` | Live activity for KYB-complete organizations explicitly invited by Stabyl |
| Staging | `https://api-staging.stabyl.com/v1` | Integration testing before launch |
## Request Conventions
| Header | Required | Notes |
| - | - | - |
| `X-Api-Key` | Most endpoints | Required except documented public endpoints |
| `Content-Type: application/json` | JSON writes | Required for request bodies |
| `Idempotency-Key` | Order writes | Send a UUID on every create, cancel, and replace. A non-UUID value is rejected. If omitted the server generates one and the request cannot be retried safely |
| `X-Stabyl-Timestamp` | Withdrawal quote and submit | Unix milliseconds within 30 seconds of server time |
| `X-Stabyl-Nonce` | Withdrawal quote and submit | New UUIDv4 or UUIDv7 per HTTP attempt |
| `X-Stabyl-Signature` | Withdrawal quote and submit | Unpadded base64url Ed25519 signature |
Use query strings for `GET` filters and JSON bodies for `POST`, `PUT`, and `PATCH` operations that accept a body. Send all times in UTC. Treat amount fields as decimal strings.
## Simulation
Simulation endpoints are non-production utilities. They can create test deposits, test withdrawal records, test KYB states, and test product update notifications outside production. They are useful for end-to-end testing when you need deterministic activity without moving real funds.
Simulation withdrawals are testing records only. Production Partner API withdrawals require an explicitly enabled key and signed quote or submit request.
## Environment Checklist
* Use a staging key with `https://api-staging.stabyl.com/v1`.
* Complete KYB and receive an explicit invitation from Stabyl before production onboarding.
* After invitation, use the provisioned production key only with `https://api.stabyl.com/v1`.
* Do not route simulation calls from production code.
* Keep separate storage for staging and production cursors, order IDs, transaction IDs, and saved recipients.
* Verify base URL and key environment before investigating authentication or access failures.
# Errors
Source: https://docs.stabyl.com/docs/errors
Error response shape, status codes, retry behavior, and logging guidance.
Errors use a JSON envelope with a stable upper-case code and human-readable message.
```json theme={null}
{
"status": "error",
"error": {
"code": "UNAUTHORIZED",
"message": "missing X-Api-Key header"
}
}
```
Do not parse `message` for control flow. Use HTTP status and `error.code` for programmatic handling.
A request that fails before it can be parsed does not get the envelope. A
missing or wrong `Content-Type` header returns `415`, malformed JSON returns
`400`, and an unknown or missing body field returns `422`, each with a
plain-text body. Branch on the HTTP status first, then on `error.code` when a
JSON body is present.
## Error Codes
`error.code` is one of a fixed set of upper-case values. The ones you are most
likely to handle:
| Code | Typical status | Meaning |
| - | - | - |
| `VALIDATION_FAILED` | `400` | A field, filter, or enum value is invalid |
| `UNAUTHORIZED` | `401` | Missing or invalid API key |
| `SIGNING_REQUIRED`, `INVALID_SIGNATURE`, `INVALID_TIMESTAMP` | `401` | A signed withdrawal request failed verification |
| `INSUFFICIENT_BALANCE` | `402` | Available balance cannot cover the request |
| `FORBIDDEN` | `403` | Missing scope on an ordinary route, or an account requirement such as KYB is not met |
| `MISSING_SCOPE` | `403` | Missing scope on a signed withdrawal route |
| `NOT_FOUND` | `404` | Unknown identifier or outside your account |
| `CONFLICT` | `409` | Current resource state prevents the change |
| `REPLAYED_REQUEST` | `409` | A signed nonce was reused |
| `IDEMPOTENCY_CONFLICT` | `409` | A withdrawal idempotency key was reused for a different intent |
| `SECRET_ROTATION_IN_PROGRESS` | `409` | A webhook secret was rotated again during the overlap window |
| `ACCOUNT_WITHDRAWAL_LIMIT_EXCEEDED` | `422` | The withdrawal total exceeds your account policy |
| `UNPROCESSABLE_ENTITY` | `422` | Valid JSON that cannot be processed, such as a rejected webhook URL |
| `RATE_LIMITED` | `429` | Too many requests for this key |
| `SERVICE_UNAVAILABLE`, `SIGNING_VERIFICATION_UNAVAILABLE`, `PARTNER_WEBHOOKS_DISABLED` | `503` | Temporarily unavailable; retry with backoff |
| `INTERNAL_SERVER_ERROR`, `BAD_GATEWAY` | `500`, `502` | Unexpected failure; retry with bounded backoff |
Withdrawal-specific codes such as `UNSUPPORTED_WITHDRAWAL_ROUTE` are described
on [Single withdrawals](/docs/withdrawals#errors).
## Common Status Codes
| Status | Meaning | Action |
| - | - | - |
| `400` | Invalid request | Fix parameters, filters, enum values, or body fields |
| `401` | Missing or invalid API key | Check `X-Api-Key` |
| `402` | Insufficient available balance | Reconcile balances and reduce or defer the request |
| `403` | Access or account requirement not met | Confirm key access and account readiness |
| `404` | Resource not found | Verify the public identifier and account context |
| `409` | Conflict | Check idempotency, duplicate intent, or current resource state |
| `415` | Missing or wrong `Content-Type` | Send `Content-Type: application/json` on JSON writes |
| `422` | Valid JSON that cannot be processed | Correct the business input, or reduce the amount for a withdrawal limit |
| `429` | Too many requests | Back off and retry later |
| `500`, `502` | Unexpected server failure | Retry with bounded backoff and alert if it persists |
| `503` | Service temporarily unavailable | Use bounded backoff and preserve retry safety |
## Retry Guidance
| Class | Retry? | Notes |
| - | - | - |
| `400` | No | The request is malformed or semantically invalid |
| `401` | No | Fix credentials or environment |
| `402` | No | Restore available balance or submit a smaller valid request |
| `403` | No | Fix key access or account readiness |
| `404` | Usually no | Retry only if you expect eventual visibility after a recent action |
| `409` | Depends | For a withdrawal idempotency conflict, read the existing transaction or create a new intent; for a replayed nonce, retry with a new nonce |
| `422` | No | Correct the request's business input, or wait and reduce the amount for a withdrawal limit |
| `429` | Yes, after backoff | Slow down the caller and keep idempotency keys stable for order writes |
| `5xx` | Yes, bounded | Retry with backoff while preserving the operation's idempotency rules |
For a signed withdrawal retry, keep the exact body and its business
`idempotency_key`, then generate a new timestamp, nonce, and signature for the
new HTTP attempt. See [Withdrawals](/docs/withdrawals). Do not apply generic
retry logic that changes the withdrawal body.
## Required Headers
Use `X-Api-Key` on authenticated endpoints. Use `Content-Type: application/json` on JSON writes. Use `Idempotency-Key` on order create, cancel, and replace requests.
# Exchange Overview
Source: https://docs.stabyl.com/docs/exchange-overview
Market data, order lifecycle, and status reconciliation for account trading automation.
The exchange routes let approved Stabyl accounts automate trading activity that
would otherwise be performed in the product interface. Discover markets first,
validate amounts and precision locally, submit order intents with idempotency,
consume exchange events, and reconcile periodically from order reads.
## Core Objects
| Object | Description |
| - | - |
| Market | A tradable pair such as `USD/NGN`, with its price tick, minimum sizes, and fee rates |
| Ticker | Current best bid/ask and selected rolling-window statistics for a pair |
| Order book | Aggregated price levels available for a pair at a point in time |
| Trade | A recent public market execution |
| Fill | One execution against one of your orders, returned inside order detail |
| Order | Your instruction to buy or sell a base asset against a quote asset |
Use IDs and symbols exactly as returned by the API. Do not infer pair names, decimal precision, minimum sizes, or route availability from hardcoded configuration.
Today, the supported exchange pair is `USD/NGN`. As new pairs are added, they will appear in `GET /partner/exchange/markets`; build against discovery rather than assuming this list is permanent.
## Market Data
Market data routes are read-only and should be used before order submission and during reconciliation:
| Need | Endpoint family |
| - | - |
| Discover tradable products | `GET /partner/exchange/markets` |
| Show a current quote | `GET /partner/exchange/ticker/{interval}` |
| Inspect depth before placing a limit order | `GET /partner/exchange/orderbook/{pair_id}/snapshot` |
| Draw charts or backfill candles | `GET /partner/exchange/candles` |
| Review recent public executions | `GET /partner/exchange/trades` and `GET /partner/exchange/fills` |
| Reconcile your own fills | `GET /partner/exchange/orders/{order_id}` (`fills` list) |
| Compare account volume and execution P\&L | `GET /partner/exchange/trade-statistics` |
Market data changes frequently. Treat responses as snapshots, cache only for short intervals, and refresh before sending price-sensitive orders.
## Quotes
Create a quote with `POST /partner/exchange/quotes` (`trades:view`), then accept
it with `POST /partner/exchange/quotes/{quote_id}/accept` (`trades:create`).
Use the returned expiry and protected price. See [Order Management](/docs/order-management#create-and-accept-a-quote).
## Orders
An order request is an instruction, not a guarantee that the order has fully executed. The create route validates the request, records the intent, and returns an order that may still be pending. Matching and final settlement are asynchronous.
| Order type | Use when | Required price |
| - | - | - |
| `limit` | You want execution at a specific price or better | Yes |
| `market` | You prefer immediate execution against available liquidity | No |
| Side | Meaning |
| - | - |
| `buy` | Spend quote currency to acquire base currency |
| `sell` | Sell base currency for quote currency |
For example, on `USD/NGN`, `USD` is the base currency and `NGN` is the quote currency.
## Order Lifecycle
Orders can move through the following statuses:
| Status | Meaning |
| - | - |
| `pending` | Request accepted for processing |
| `cancelling` | Returned only by the cancel route while the cancel is being applied |
| `replacing` | Returned only by the replace route while the replacement is being applied |
| `accepted` | Order is live or eligible for matching |
| `partially_filled` | Some quantity has executed |
| `filled` | Full quantity has executed |
| `cancelled` | Remaining quantity was cancelled |
| `rejected` | Order was not accepted |
| `expired` | The order expired without remaining live quantity |
Only `filled`, `cancelled`, `rejected`, and `expired` should be treated as
terminal. A `partially_filled` order still has remaining quantity unless the
order later moves to a terminal state.
## Direct Order Reconciliation
1. Generate a UUID `Idempotency-Key` and a unique `client_order_id`, and store both with the local order intent.
2. Submit the order with `POST /partner/exchange/orders` and store the returned `order_id`.
3. If the create response is uncertain, retry with the same `Idempotency-Key`. A retry answers with a new `order_id`; the duplicate command is discarded, so reconcile by `client_order_id` rather than trusting the retry's ID. See [Idempotency](/docs/idempotency).
4. Apply exchange webhooks or private WebSocket updates to the local order.
5. Periodically reconcile order detail, its fills, and wallet transactions from REST.
6. Run reconciliation after event-consumer downtime or a connection reset.
Never assume a network timeout means an order did not exist. Query the order state before creating a replacement.
Authenticated exchange endpoints require `X-Api-Key` with `trades:view` for reads, `trades:create` for order creation, and `trades:execute` for cancel and replace. Send `Idempotency-Key` on direct create, cancel, and replace requests. Quote acceptance uses the quote ID instead.
# Fiat Deposit Accounts
Source: https://docs.stabyl.com/docs/fiat-deposit-accounts
Retrieving fiat deposit account details and verifying bank accounts for saved recipients.
Use fiat wallet routes to show incoming deposit details and confirm bank account names before saving recipient records.
| Endpoint | Purpose |
| - | - |
| `GET /partner/wallets/fiat/banks` | List supported banks |
| `POST /partner/wallets/fiat/verify-account` | Confirm account name before saving a recipient |
| `GET /partner/wallets/fiat/deposit-account` | Retrieve the incoming fiat deposit account |
The deposit account is for incoming fiat deposits. Account verification is for saved recipient setup and account-name confirmation.
Production requires approved KYB before the API returns an existing NGN deposit
account, provisions a new account, or verifies recipient bank details. A
non-approved account receives `403`, including when deposit details were
provisioned previously. Development and staging skip this gate.
## List Supported Banks
Bank codes can change. Load them from the API instead of maintaining a static list in your application.
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/wallets/fiat/banks
```
Each bank has an `institution_code` and, for some banks, a shorter `bank_code`.
Wherever the API asks for a `bank_code`, either value is accepted.
`institution_code` is always present, so it is the safer one to store.
## Verify A Bank Account
Use verification before creating a fiat recipient so operators can confirm the account name they expect.
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/wallets/fiat/verify-account \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-d '{
"bank_code": "000013",
"account_number": "0123456789"
}'
```
Verification confirms account details. It does not create a recipient and it does not move funds. It returns `503` when the bank lookup is temporarily unavailable; retry with backoff.
## Retrieve Deposit Account
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/wallets/fiat/deposit-account \
-H "X-Api-Key: $STABYL_API_KEY"
```
An optional `currency` query parameter defaults to `NGN`, the only supported
value; anything else returns `400`. Show the returned account name, bank name,
and account number exactly. Reconcile incoming credits from `GET /partner/wallets/transactions`.
## Required Headers
`GET /partner/wallets/fiat/banks` is public. Verification and deposit account reads require `X-Api-Key`; verification requires key access to recipient setup, and deposit account reads require wallet read access.
Common failures are `400` for an invalid bank code, account number, or currency, `403` for a key without the needed scope or an account whose production KYB is not approved, and `503` when bank verification is temporarily unavailable.
# Go-Live Checklist
Source: https://docs.stabyl.com/docs/go-live-checklist
Production readiness checklist for API automation.
Before moving to production, your organization must complete KYB and receive an
explicit invitation from Stabyl. Staging access and successful staging tests do
not grant production access. Follow the instructions in the invitation to
provision production credentials.
## Checklist
| Area | Requirement |
| - | - |
| Onboarding | KYB is complete and Stabyl has explicitly invited the organization to production |
| Authentication | API keys are stored server-side and rotated through an approved process |
| Orders | Create, cancel, and replace calls send a UUID `Idempotency-Key`, and orders carry a `client_order_id` |
| Wallets | Chain availability is read from `GET /partner/wallets/chains` |
| Deposits | Crypto routes and fiat deposit accounts are shown exactly as returned |
| Withdrawals | Ed25519 signing, idempotent retries, and partial outcomes are handled |
| Errors | `400`, `401`, `402`, `403`, `404`, `409`, `415`, `422`, `429`, and `5xx` responses are handled, including plain-text bodies on parsing failures |
| Network | Each production key carries an IP or CIDR allowlist where your egress is fixed |
| Simulation | Simulation calls are removed from production workflows |
## Required Headers
Use `X-Api-Key` on authenticated endpoints and `Content-Type: application/json` on JSON writes. Always send a UUID `Idempotency-Key` on order writes; the server does not reject a request without one, but that request cannot be retried safely. Use the request-body `idempotency_key` on withdrawals. Withdrawal quote and submit calls also require a timestamp, a new nonce, and an Ed25519 signature.
Common launch blockers include incomplete KYB, unsupported chain assumptions, or retry logic that changes idempotency keys.
## Production Verification
Before enabling automation against production funds:
1. Confirm KYB is complete and retain the explicit production invitation from Stabyl.
2. Provision production credentials by following the invitation instructions.
3. Run a full staging order lifecycle: create, read, cancel, replace, and reconcile.
4. Run a staging deposit test for every chain you plan to display.
5. Confirm your reconciliation job can resume after downtime without duplicating records.
6. Run a signed staging withdrawal through quote, direct submit, and transaction reconciliation.
7. Confirm alerts exist for repeated `401`, `403`, `409`, `429`, and `5xx` responses.
8. Confirm API keys are not present in frontend code, mobile apps, analytics tools, or browser logs.
# Idempotency
Source: https://docs.stabyl.com/docs/idempotency
Safe retries for quote acceptance and direct order requests.
## Quote Acceptance
Accept a quote with `POST /partner/exchange/quotes/{quote_id}/accept`. No separate
`Idempotency-Key` is needed. Retry an uncertain response with the same quote ID;
retries use the same order ID. Check that order before starting a new quote.
An expired, unaccepted quote returns `409` and must be replaced with a fresh quote.
Creating another quote does not retry an earlier acceptance.
## Direct Order Requests
The rules below apply to `/partner/exchange/orders` and its cancel/replace routes.
Send `Idempotency-Key` on each of these requests.
```http theme={null}
Idempotency-Key: 0198b730-7cc2-79e4-b04e-5b5c41a50221
```
Generate the key in your backend as a UUID; any other format is rejected with
`400`. If `idempotency_key` also appears in the JSON body, it must match the
header. If you send neither, the server generates a key for you and the request
cannot be retried safely.
## How Order Idempotency Works
The key protects against a duplicate command reaching the exchange. It
does not replay the original response.
* The first request with a key is processed and answered with a fresh
`order_id`.
* A retry with the same key within a limited window is also answered `200` with
a fresh `order_id`, but its command is discarded as a duplicate. That second
`order_id` never becomes a real order.
* A request with the same key but a different body is not rejected. Inside the
window it is discarded like any duplicate; after the window it is a new order.
* After the window has passed, the same key creates a second order.
Because of this, treat the `order_id` from a retry as untrusted and reconcile
from `client_order_id`, which is unique within your account, or from an order
list filtered to the pair and status you expect.
## What Counts As The Same Intent
Use one idempotency key for one business action:
| Action | Same intent |
| - | - |
| Create order | Same pair, side, type, quantity, price, time in force, `client_order_id`, and account |
| Cancel order | Same order ID and cancel attempt |
| Replace order | Same original order ID and replacement values |
If you change the order quantity, price, side, or pair, generate a new key. If you are only retrying because the network failed or the response was uncertain, reuse the original key.
## Storage Pattern
Store the idempotency key before sending the API request.
```json theme={null}
{
"local_action_id": "order-create-2026-05-24-0001",
"idempotency_key": "0198b730-7cc2-79e4-b04e-5b5c41a50221",
"request_hash": "sha256-of-your-local-intent",
"remote_order_id": null,
"status": "submitted"
}
```
When the first attempt returns an `order_id`, attach it to the local action. If
the request times out before a response, retry with the same key, then find the
live order by `client_order_id` and attach that `order_id` instead of the one the
retry returned.
## Response Handling
| Response | Meaning | Recommended action |
| - | - | - |
| `2xx` | Command accepted; on a retry the duplicate is discarded and the returned ID is not live | Store the first attempt's ID; reconcile retries by `client_order_id` |
| `400` | Request is malformed, the key is not a UUID, or the header and body keys differ | Fix the request before retrying |
| `401` | API key missing or invalid | Stop and rotate or correct credentials |
| `403` | Trading suspended, KYB not approved, or missing scope | Resolve the condition; do not retry blindly |
| `429` | Too many requests | Back off and retry with the same key |
| `5xx` | Outcome may be uncertain | Retry with the same key, then reconcile |
## Checklist
* Store the key and a unique `client_order_id` with your local order action before sending the request.
* Retry uncertain responses with the same key, then reconcile by `client_order_id`.
* Generate a new key only for a new intent.
* Do not expect a `409`; order writes never return one.
* Always use a UUID; other formats are rejected.
* Keep idempotency keys out of logs if your logs are widely accessible.
# Introduction
Source: https://docs.stabyl.com/docs/introduction
Build server side automations for your own Stabyl account.
The Stabyl API lets your team automate the same money movement and trading workflows you already perform inside Stabyl. Use it for backend jobs, internal tools, reconciliation systems, and trading automation that operate on your own Stabyl account.
Create your staging API keys in the
[Stabyl staging app](https://staging-app.stabyl.com). Staging keys work only
with `https://api-staging.stabyl.com/v1`. Staging access does not grant
production access.
Production API access requires completed KYB and an explicit invitation from
Stabyl. The invitation provides the production onboarding instructions. Do not
send requests to the production API until your organization has received that
invitation and provisioned production credentials.
## What You Can Automate
| Area | What the API gives you |
| - | - |
| Wallet discovery | Supported wallet currencies, stablecoin rails, and blockchain routes |
| Deposits | Fiat deposit account details and one crypto deposit address per requested chain |
| Balances | NGN and USD wallet balances, including available and locked amounts |
| Recipients | Saved fiat and crypto recipient records used by Stabyl Pro workflows |
| Withdrawals | Signed NGN or stablecoin withdrawal quotes and direct submission |
| Transactions | Cursor-paginated wallet activity for deposits, fees, adjustments, refunds, and withdrawals |
| Market data | Markets, tickers, order books, candles, and the public trade tape |
| Orders | Estimate, create, list, read, cancel, and replace exchange orders |
| Sandbox | Non-production simulation endpoints for testing deposits, withdrawal records, KYB state, and notifications |
Partner withdrawals are authorized by an API key with the `wallets:withdraw`
scope and an Ed25519 request signature. They do not require an emailed
verification code or a separate withdrawal token after submission.
## Base URLs
| Environment | URL |
| - | - |
| Production | `https://api.stabyl.com/v1` |
| Staging | `https://api-staging.stabyl.com/v1` |
The production URL is available only to organizations that have completed KYB
and been invited by Stabyl.
## API Conventions
All requests use HTTPS. JSON writes require `Content-Type: application/json`. Authenticated routes require `X-Api-Key`.
Successful JSON responses use a response envelope:
```json theme={null}
{
"status": "success",
"data": {}
}
```
Errors use the same envelope shape:
```json theme={null}
{
"status": "error",
"error": {
"code": "UNAUTHORIZED",
"message": "missing X-Api-Key header"
}
}
```
One exception: a request that cannot be parsed at all, such as a missing
`Content-Type: application/json` header (`415`), malformed JSON (`400`), or an
unknown body field (`422`), is rejected before the envelope is built and
returns a plain-text body. Branch on the HTTP status first and only then on
`error.code`.
Timestamps are RFC3339 strings in UTC. Monetary amounts are strings to avoid floating point rounding issues. Stablecoins (`USDT`, `USDC`) are settlement rails for the USD wallet, not separate wallet currencies.
## Design Principles
1. Keep API keys server-side and grant each key only the access it needs.
2. Discover wallet routes from the API instead of hardcoding chain availability.
3. Treat order writes as asynchronous; read the order after submission for canonical status.
4. Send a UUID `Idempotency-Key` on every create, cancel, and replace order request, and set `client_order_id` on orders you must be able to find again.
5. Reuse a withdrawal body idempotency key only for retries of the same request.
6. Reconcile from transaction and order reads, not from manual exports or UI state.
7. Remove simulation calls from production code paths.
# Market Data
Source: https://docs.stabyl.com/docs/market-data
Discover markets, price orders, and reconcile execution activity.
Market data routes help you discover supported pairs, show current prices, inspect order book depth, and reconcile execution activity. They are designed for backend trading automation and internal tools that need deterministic product metadata before placing orders.
| Endpoint | Use |
| - | - |
| `GET /partner/exchange/markets` | Supported pairs, price tick, minimum sizes, and fee rates |
| `GET /partner/exchange/ticker/{interval}` | Current best bid/ask and selected-window statistics |
| `GET /partner/exchange/orderbook/{pair_id}/snapshot` | Bid and ask depth for a pair |
| `GET /partner/exchange/orderbook/{pair_id}/levels` | One-side depth for focused quote displays |
| `GET /partner/exchange/candles` | OHLCV candles for charting and historical checks |
| `GET /partner/exchange/trades` | Recent public executions, resumable by `after_sequence` |
| `GET /partner/exchange/fills` | Recent public executions for one pair, latest first (not your account's fills) |
| `GET /partner/exchange/trade-statistics` | Account volume and execution P\&L comparisons |
## Market Discovery
Call `GET /partner/exchange/markets` during startup and refresh it periodically. Use the returned values as the source of truth for:
* supported `pair_id` values and their `base_currency` and `quote_currency`
* `tick_size`, the price increment
* `min_base_lot`, the smallest order quantity, and `min_notional`, the smallest order value in quote currency
* `maker_fee_bps` and `taker_fee_bps`
There is no maximum order size. Order types and time-in-force values are the
same for every market and are listed on [Order management](/docs/order-management).
Do not submit an order for a pair that is missing or whose price and quantity
fall outside the returned limits; such orders return `400`. If trading on a
pair is suspended, order creation and replacement return `403` even though the
pair is still listed.
The currently supported exchange pair is `USD/NGN`. Future pairs should be discovered from this endpoint when they become available.
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/exchange/markets \
-H "X-Api-Key: $STABYL_API_KEY"
```
## Pricing Before Order Submission
Use ticker data for lightweight quote displays. The path interval is required and accepts exactly `24h`, `1d`, `7d`, `30d`, `180d`, or `360d`. `24h` and `1d` are equivalent one-day aliases. The response echoes the requested alias and returns the exact rolling `[from, to)` calculation window.
Open, high, low, last, price change, executed volume, executed quote notional, and trade count use the selected interval. Best bid and best ask are the freshest top-of-book values available, not the highest bid or lowest ask observed during the interval. When the live book is momentarily unavailable they fall back to the most recently recorded top of book, and either side can be `null`.
Ticker responses can be cached for up to one second. Treat the returned `to` and each ticker's identical `timestamp` as the calculation time.
```bash theme={null}
curl "https://api-staging.stabyl.com/v1/partner/exchange/ticker/7d?pair_id=USD%2FNGN" \
-H "X-Api-Key: $STABYL_API_KEY"
```
`pair_id` is optional. With a pair filter, an interval with no executions still returns one ticker with null historical price fields and zero volume/count. Without a filter, the API returns only pairs that executed during the selected interval, ordered by canonical pair ID.
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/exchange/orderbook/USD%2FNGN/snapshot \
-H "X-Api-Key: $STABYL_API_KEY"
```
## Candles
Candles use a bounded datafeed-style request: the client chooses the exact visible UTC window with `from` and `to`, and independently chooses the width of one candle with `interval`.
```bash theme={null}
curl "https://api-staging.stabyl.com/v1/partner/exchange/candles?pair_id=USD%2FNGN&interval=1h&from=2026-08-27T12%3A00%3A00Z&to=2026-09-03T12%3A00%3A00Z" \
-H "X-Api-Key: $STABYL_API_KEY"
```
Every candle query requires:
| Parameter | Meaning |
| - | - |
| `pair_id` | Supported market identifier; the response returns canonical slash form |
| `interval` | One candle's width: `1m`, `5m`, `15m`, `1h`, `4h`, or `1d` |
| `from` | Inclusive RFC 3339 UTC start, aligned exactly to `interval` |
| `to` | Exclusive RFC 3339 UTC end, aligned exactly to `interval` and later than `from` |
There is no `range`, `before`, or `limit` parameter. The request may span at most 500 theoretical buckets, including buckets with no trades. Results contain non-empty buckets only, ordered oldest-first. Stabyl does not synthesize prices or carry a previous close through missing activity.
For candlesticks, use `open`, `high`, `low`, and `close` directly. For a line series, plot `close` at the returned bucket-start `timestamp`; do not reindex returned values across omitted buckets. Candle responses can be cached for up to five seconds, so use `as_of` as the calculation time.
`as_of` is the server calculation time. `is_closed=false` means the final returned candle extends beyond `as_of` and can change. To request the currently forming candle, set `to` to the next aligned candle boundary.
Use these client presets for the analytics UI:
| UI range | Window | Recommended `interval` | Points at most |
| - | - | - | - |
| `1D` | 1 day | `5m` | 288 |
| `1W` | 7 days | `1h` | 168 |
| `1M` | 30 days | `4h` | 180 |
| `6M` | 180 days | `1d` | 180 |
| `1Y` | 360 days | `1d` | 360 |
The range labels are client behavior, not candle intervals. A one-year view uses `interval=1d`; `interval=360d` is not supported because it would mean one 360-day candle.
## Recent Executions
Both routes return public executions and require `pair_id`. `GET /partner/exchange/trades` accepts `after_sequence` and `limit` (default 50, maximum 200), so you can resume from the last sequence you processed. `GET /partner/exchange/fills` accepts `limit` only (default 50, maximum 100) and always returns the latest executions. Neither route is filtered to your account; your own executions are the `fills` list on `GET /partner/exchange/orders/{order_id}`.
Order book depth is controlled with `levels` on the snapshot route (default 50, maximum 500) and with a required `side` of `bid` or `ask` plus `limit` (default 20, maximum 500) on the levels route.
## Operational Guidance
| Pattern | Recommendation |
| - | - |
| Startup | Fetch markets before enabling trading automation |
| Quote display | Refresh ticker data on a short interval |
| Charting | Keep visible range (`from`/`to`) separate from candle resolution |
| Order entry | Read market metadata and current price before validation |
| Reconciliation | Store order IDs, then read each order's `fills` list |
| Caching | Cache product metadata longer than live price data |
## Required Headers
Market data endpoints require `X-Api-Key` with the `trades:view` scope.
Common `400` failures include unsupported `pair_id`, unsupported intervals, unaligned candle boundaries, `to <= from`, or a candle window above 500 theoretical buckets.
# Order Management
Source: https://docs.stabyl.com/docs/order-management
Create, list, read, cancel, and replace exchange orders.
Order routes automate trading activity for your Stabyl account. Order creation
requires `X-Api-Key` with `trades:create`; cancel and replace require
`trades:execute`. Send a UUID `Idempotency-Key` for direct order creation,
cancellation, and replacement. Quote acceptance uses the quote ID instead. Matching is
asynchronous. Consume
exchange webhooks or private WebSocket topics for timely changes, and use order
reads for recovery, operator lookups, and periodic reconciliation.
## Before You Place an Order
Perform these checks in your own system before sending the request:
1. Fetch `GET /partner/exchange/markets` and confirm the pair is listed.
2. Validate `side`, `order_type`, `quantity`, and `price` against `tick_size`, `min_base_lot`, and `min_notional`.
3. Confirm available balance with `GET /partner/wallets/balances/overview`. Creation does not check balance; an unfunded order is accepted and later reported as `rejected`.
4. Generate and store a UUID `Idempotency-Key` and a unique `client_order_id` for this exact order intent.
5. Configure exchange webhooks or private WebSocket topics before submission.
## Request Fields
| Field | Required | Notes |
| - | - | - |
| `pair_id` | Yes | Canonical pair such as `USD/NGN` |
| `side` | Yes | `buy` or `sell` |
| `order_type` | Yes | `limit` or `market` |
| `quantity` | Yes | Base-asset amount as a decimal string, at least `min_base_lot` |
| `price` | Limit | Decimal string on the `tick_size` grid. Ignored for market orders |
| `time_in_force` | No | `GTC` (default for limit), `IOC`, `FOK`, or `POST_ONLY`. Market orders are always `IOC` |
| `client_order_id` | No | Up to 128 characters, unique within your account. Generated when omitted |
| `idempotency_key` | No | Same value as the `Idempotency-Key` header when both are present |
Unknown fields are rejected. `POST_ONLY` cancels the order instead of taking
liquidity when it would cross the book.
## Create A Limit Order
Limit orders specify the maximum buy price or minimum sell price you are willing to accept.
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/exchange/orders \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-H "Idempotency-Key: 0198b730-7cc2-79e4-b04e-5b5c41a50221" \
-d '{
"pair_id": "USD/NGN",
"side": "buy",
"order_type": "limit",
"price": "1520.00",
"quantity": "100.00"
}'
```
## Create And Accept A Quote
Create a quote with `trades:view`:
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/exchange/quotes \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-d '{
"pair_id": "USD/NGN",
"side": "sell",
"quantity": "50.00",
"business_bps": 25
}'
```
The response includes `quote_id`, `expires_at`, estimated pricing and fees, and
`protected_limit_price`: the highest buy price or lowest sell price allowed.
Quotes expire after 30 seconds; use the returned `expires_at` for your countdown.
`business_bps` is an optional display spread, added for buys and subtracted for
sells. It does not change execution or Stabyl fees. Fees are estimates until the
order is accepted for execution.
Accept with `trades:create`, no body, and no `Idempotency-Key`:
```bash theme={null}
curl "https://api-staging.stabyl.com/v1/partner/exchange/quotes/$QUOTE_ID/accept" \
-X POST \
-H "X-Api-Key: $STABYL_API_KEY"
```
The response contains `quote_id`, `order_id`, `status: "pending"`, and
`accepted_at`. Check the returned order for the final result. Liquidity is not
reserved: fills may be partial or zero, and any unfilled remainder is cancelled.
Retry an uncertain acceptance with the **same quote ID**, including after expiry
if acceptance had already begun. An expired, unaccepted quote returns `409`;
request a fresh quote. Unknown or another account's quotes return `404`.
Do not also submit a separate order for the same quote.
`POST /partner/exchange/orders/estimate` is deprecated. It remains supported with
the same request and response; use `/partner/exchange/quotes` for new integrations.
## Create A Market Order
To submit a market order directly, use `/partner/exchange/orders`. This does not
accept a quote or use its protected price.
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/exchange/orders \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-H "Idempotency-Key: 0198b730-7cc2-79e4-b04e-5b5c41a50222" \
-d '{
"pair_id": "USD/NGN",
"side": "sell",
"order_type": "market",
"quantity": "50.00"
}'
```
## Direct Order Response
The response confirms that Stabyl accepted the order request for processing. It does not mean the order is already filled.
```json theme={null}
{
"status": "success",
"data": {
"order_id": "0198b731-2830-7f5d-a10d-7a5fda480491",
"status": "pending",
"created_at": "2026-09-09T08:12:04+00:00"
}
}
```
Store the returned `order_id`. Use it for all later reads, cancels, replacements, and reconciliation. The response does not echo the order fields; read the order or wait for `exchange.accepted` to see them.
## List Orders By Status
```bash theme={null}
curl "https://api-staging.stabyl.com/v1/partner/exchange/orders?status=accepted" \
-H "X-Api-Key: $STABYL_API_KEY"
```
Filters are `status` (`pending`, `accepted`, `partially_filled`, `filled`, `cancelled`, `rejected`, `expired`), `pair_id`, `limit` (default 50, maximum 100), and `offset`. An unrecognised `status` value is ignored rather than rejected. The response contains `orders` and `count` for the page; there is no cursor or total. Use list filters for dashboards and background reconciliation. For a single order, prefer the detail endpoint, which also returns the order's `fills`.
## Get Order Detail
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/exchange/orders/0198b731-2830-7f5d-a10d-7a5fda480491 \
-H "X-Api-Key: $STABYL_API_KEY"
```
## Cancel Order
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/exchange/orders/0198b731-2830-7f5d-a10d-7a5fda480491/cancel \
-X POST \
-H "X-Api-Key: $STABYL_API_KEY" \
-H "Idempotency-Key: 0198b730-7cc2-79e4-b04e-5b5c41a50223"
```
The response is `{ "order_id", "status": "cancelling", "cancelled_at" }`. Cancel is accepted only while the order is `pending`, `accepted`, or `partially_filled`; any other status returns `400`. It is best effort for open quantity: if part of the order has already executed, the filled portion remains final and only remaining quantity can be cancelled. Confirm the outcome from `exchange.cancelled` or an order read.
## Replace Order
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/exchange/orders/0198b731-2830-7f5d-a10d-7a5fda480491/replace \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-H "Idempotency-Key: 0198b730-7cc2-79e4-b04e-5b5c41a50221" \
-d '{
"price": "1518.00",
"quantity": "100.00"
}'
```
Replace applies to live `limit` orders in `accepted` or `partially_filled` status; a `pending` or market order returns `400`. Send at least one of `price` or `quantity`. Omitting `quantity` keeps the remaining unfilled quantity, and an optional `client_order_id` names the replacement. The response is `{ "original_order_id", "new_order_id", "status": "replacing", "replaced_at" }`. Store `new_order_id`: the original is cancelled and the replacement continues under the new ID. Always read the returned order and do not assume the original order is still active.
## Status Handling
| Status | Your action |
| - | - |
| `pending` | Wait for an event and include it in periodic reconciliation |
| `accepted` | Treat as live until a later event or reconciliation update |
| `partially_filled` | Apply each fill event and show executed and remaining amounts |
| `filled` | Treat as terminal and reconcile all fills |
| `cancelled` | Treat as terminal and reconcile any partial fills |
| `rejected` | Treat as terminal and surface the public reason |
| `expired` | Treat as terminal when returned by REST reconciliation |
`cancelling` and `replacing` appear only in the cancel and replace responses
while the change is being applied; the order itself then moves to `cancelled`
or is superseded by the replacement.
## Retry Rules
These rules apply to direct order requests. For quote acceptance, retry the same
quote ID as described above.
| Situation | What to do |
| - | - |
| Request timed out before a response | Retry with the same `Idempotency-Key`, then reconcile by `client_order_id`; the retry's `order_id` is not the live order |
| Response was `429` | Back off, then retry with the same `Idempotency-Key` |
| Response was `403` | Trading is suspended on the pair, production KYB is not approved, or the key lacks the scope. Do not retry until resolved |
| Order status is terminal | Do not retry the same action as a new order |
Order writes return `400` for an invalid pair, price, quantity, or status
transition, `401`, `403`, `404` for an unknown or foreign order, `429`, and
`503`. They never return `409`. Insufficient balance is not a write-time
failure; it surfaces later as a `rejected` order.
# Quickstart
Source: https://docs.stabyl.com/docs/quickstart
Make the first calls for wallet discovery, balances, market data, orders, and reconciliation.
Use staging while building automation. Keep `X-Api-Key` in a backend service, job runner, or secure internal tool. Never expose it in browser, mobile, desktop, or user-controlled code.
```mermaid theme={null}
sequenceDiagram
participant App as Your Service
participant API as Stabyl API
App->>API: POST /partner/webhooks
App->>API: GET /partner/wallets/chains
App->>API: GET /partner/wallets/crypto/address?chain=tron
App->>API: GET /partner/wallets/balances/overview
App->>API: GET /partner/exchange/markets
App->>API: POST /partner/exchange/orders
App->>API: POST signed /partner/wallets/withdrawals
API-->>App: Signed webhook events
App->>API: Periodic REST reconciliation
```
## 0. Configure Your Environment
Create a staging API key in the
[Stabyl staging app](https://staging-app.stabyl.com), then configure your
server-side environment:
```bash theme={null}
export STABYL_BASE_URL="https://api-staging.stabyl.com/v1"
export STABYL_API_KEY="sb_test_..."
```
When you create the key, select every scope this integration needs. The
default scope set covers wallet and trading reads and writes but does **not**
include `webhooks:manage`, `webhooks:view`, or `wallets:withdraw`. Step 1 below
needs `webhooks:manage`, and step 8 needs `wallets:withdraw`. Scopes cannot be
added to an existing key, so a key created without them must be replaced.
Use staging keys against staging URLs. Production API access requires completed
KYB and an explicit invitation from Stabyl; staging access does not grant
production access. After invitation, use production credentials only against
the production URL. Treat cross-environment authentication failures as
configuration errors, not transient failures.
## 1. Subscribe to webhooks
Create your HTTPS receiver before initiating asynchronous activity:
```bash theme={null}
curl "$STABYL_BASE_URL/partner/webhooks" \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-d '{
"url": "https://partner.example.com/stabyl/events",
"description": "Stabyl event receiver",
"enabled": true,
"event_types": [
"deposit.action_required",
"deposit.success",
"deposit.failed",
"withdrawal.success",
"withdrawal.partial",
"withdrawal.failed",
"exchange.accepted",
"exchange.filled",
"exchange.replaced",
"exchange.cancelled",
"exchange.rejected"
]
}'
```
Store the returned `whsec_...` secret immediately; it is shown only once. See
[Webhooks](/docs/webhooks) for signature verification and durable deduplication.
## 2. List supported chains
```bash theme={null}
curl "$STABYL_BASE_URL/partner/wallets/chains"
```
This route is public. Use it to decide which chain options to show before you request a crypto deposit address.
## 3. Request a Tron deposit address
```bash theme={null}
curl "$STABYL_BASE_URL/partner/wallets/crypto/address?chain=tron" \
-H "X-Api-Key: $STABYL_API_KEY"
```
Addresses are created only when requested for a chain. Store the returned `chain`, optional `slug`, `address`, and `memo` exactly as returned.
## 4. Read balances
```bash theme={null}
curl "$STABYL_BASE_URL/partner/wallets/balances/overview" \
-H "X-Api-Key: $STABYL_API_KEY"
```
The response carries `balances`, `withdrawable`, and `credit`. Use
`withdrawable` when deciding how much can be withdrawn right now. Use
`balances[].available` for other spendable-balance decisions such as order
sizing, and `balances[].locked` to explain funds committed to pending activity.
## 5. Create an order
```bash theme={null}
curl "$STABYL_BASE_URL/partner/exchange/orders" \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-H "Idempotency-Key: 0198b730-7cc2-79e4-b04e-5b5c41a50221" \
-d '{
"pair_id": "USD/NGN",
"side": "buy",
"order_type": "limit",
"price": "1520.00",
"quantity": "100.00"
}'
```
Order creation is asynchronous. A successful response returns `order_id`,
`status: "pending"`, and `created_at`; it means the request was accepted for
processing, not that the order is filled. `Idempotency-Key` must be a UUID. Set
`client_order_id` in the body if you need to find the order again from your own
records.
## 6. Receive order status changes
Use exchange webhooks for durable lifecycle events. Use private WebSocket topics
when your application also needs low-latency order and fill streams. REST order
detail remains available for startup recovery, missed-event repair, and operator
lookups:
```bash theme={null}
curl "$STABYL_BASE_URL/partner/exchange/orders/0198b731-2830-7f5d-a10d-7a5fda480491" \
-H "X-Api-Key: $STABYL_API_KEY"
```
## 7. Reconcile wallet activity
```bash theme={null}
curl "$STABYL_BASE_URL/partner/wallets/transactions?limit=50" \
-H "X-Api-Key: $STABYL_API_KEY"
```
Store the returned cursor and continue paging until `next_cursor` is `null`.
Run this as periodic reconciliation and after webhook downtime rather than as a
per-transaction polling loop. Reconciliation jobs should be restartable and
should tolerate records arriving after confirmation or settlement updates.
## 8. Submit a withdrawal
Follow [Withdrawal examples](/docs/withdrawal-examples) to quote, sign, and
submit NGN or stablecoin withdrawals. Persist the returned transaction ID and
complete the workflow from `withdrawal.success`, `withdrawal.partial`, or
`withdrawal.failed`; use the
transaction detail route only for targeted recovery and reconciliation.
# Rate Limits
Source: https://docs.stabyl.com/docs/rate-limits
How to handle API request limits and retry behavior.
Stabyl applies request limits to keep API performance consistent and protect
trading and wallet workflows from accidental request bursts. Limits can vary by
route and account. Do not hardcode an assumed request allowance unless Stabyl
has provided one for your production onboarding.
## Required Headers
Send `X-Api-Key` on authenticated endpoints so limits are evaluated for the correct account. Send `Idempotency-Key` on direct order create, cancel, and replace requests.
For quote acceptance, retry the same quote ID without a separate key.
## Handling Limits
When a request is rate limited, the API returns `429`. Per-key limits return
the JSON envelope with error code `RATE_LIMITED` and no `Retry-After` header.
A separate edge limiter, applied per source IP, returns `429` with a plain-text
body and a `Retry-After` header. Treat any `429` the same way regardless of
body shape: clients must have a bounded backoff policy. Use exponential backoff
with full jitter, starting near one second and capped near 30 seconds.
Signed withdrawal quote and submit calls are limited separately from, and much
more tightly than, the rest of the key's traffic. Quote as part of a user
action, not in a polling loop, and never retry a submit in a tight loop.
| Pattern | Recommendation |
| - | - |
| Stable reads | Cache route lists, currencies, and market metadata |
| Live market data | Prefer WebSocket topics when continuous updates are required |
| Order lifecycle | Prefer exchange webhooks or private WebSocket topics |
| Wallet lifecycle | Prefer deposit and withdrawal webhooks |
| Direct order writes | Retry with the same `Idempotency-Key` after a transient failure |
| Quote acceptance | Retry with the same quote ID after an uncertain response |
| Withdrawal writes | Keep the same body and business idempotency key, but sign each HTTP retry with a new timestamp and nonce |
| Batch jobs | Add jitter so scheduled jobs do not all run at the same second |
## Suggested Client Behavior
| Response | Client behavior |
| - | - |
| `429` | Use bounded exponential backoff with full jitter |
| `5xx` on a direct order write | Retry with the same `Idempotency-Key` |
| `429` or `5xx` on a signed withdrawal | Follow the signed retry rules in [Withdrawals](/docs/withdrawals) |
| repeated `429` | Slow the whole account-level queue, not only one request |
Cap attempts and surface persistent rate limiting to operators. Do not retry
validation, authentication, or permission failures as if they were rate limits.
## Event and reconciliation guidance
| Resource | Typical pattern |
| - | - |
| Markets and currencies | Refresh at startup and periodically |
| Ticker and order book | Read before an action; use WebSocket for continuous updates |
| Orders and fills | Consume webhooks or WebSocket; use REST for recovery and reconciliation |
| Wallet transactions | Consume webhooks; run cursor-based REST reconciliation periodically |
| Deposit address | Read on demand, then cache the returned address |
# Sandbox Simulation
Source: https://docs.stabyl.com/docs/sandbox-simulation
Non-production endpoints for testing wallet, KYB, and notification flows.
Simulation endpoints are available only outside production. Use them to test integration behavior without moving real funds.
| Endpoint | Purpose |
| - | - |
| `POST /simulate/fiat/deposits` | Credit a test NGN fiat deposit |
| `POST /simulate/fiat/withdrawals` | Create a test fiat withdrawal transaction |
| `POST /simulate/crypto/deposits` | Credit a test USD stablecoin or development cNGN deposit |
| `POST /simulate/crypto/withdrawals` | Create a test stablecoin or development cNGN withdrawal transaction |
| `POST /simulate/kyb/status` | Set a test user's KYB status |
| `POST /simulate/notifications/product-updates` | Insert a test product update notification |
Simulated withdrawals are sandbox records for integration testing and never move funds. Production Partner API withdrawal quote and submit calls use the signed request flow described in [Withdrawals](/docs/withdrawals).
## How To Use Simulation Safely
Simulation is for exercising your client behavior:
* deposit credit handling
* transaction timeline reconciliation
* failed or pending record handling
* KYB-gated flows in staging
* notification rendering and delivery tests
Do not reuse simulation payloads as production assumptions. Production money movement can have additional timing and review states.
The wallet simulation requests identify the target staging account by
`user_id`. Replace the example UUID below with your staging account user ID.
Simulated withdrawals run through the real balance check, so simulate a deposit
into the same wallet first or the withdrawal returns `402 INSUFFICIENT_BALANCE`.
## Example Stablecoin Deposit Simulation
```bash theme={null}
curl https://api-staging.stabyl.com/v1/simulate/crypto/deposits \
-X POST \
-H "Content-Type: application/json" \
-d '{
"user_id": "0197f1f0-0000-7000-8000-000000000001",
"chain": "tron",
"symbol": "USDT",
"amount": "25.00",
"idempotency_key": "sim_crypto_dep_001"
}'
```
After creating a simulated deposit, verify that your reconciliation process
upserts the resulting wallet transaction by transaction ID. Simulation records
do not replace testing a staging workflow that produces the webhook events your
integration subscribes to.
## Example Fiat Deposit Simulation
```bash theme={null}
curl https://api-staging.stabyl.com/v1/simulate/fiat/deposits \
-X POST \
-H "Content-Type: application/json" \
-d '{
"user_id": "0197f1f0-0000-7000-8000-000000000001",
"amount": "50000.00",
"currency": "NGN",
"idempotency_key": "sim_fiat_dep_001"
}'
```
## Example Fiat Withdrawal Simulation
```bash theme={null}
curl https://api-staging.stabyl.com/v1/simulate/fiat/withdrawals \
-X POST \
-H "Content-Type: application/json" \
-d '{
"user_id": "0197f1f0-0000-7000-8000-000000000001",
"amount": "500.00",
"currency": "NGN"
}'
```
## Example Stablecoin Withdrawal Simulation
```bash theme={null}
curl https://api-staging.stabyl.com/v1/simulate/crypto/withdrawals \
-X POST \
-H "Content-Type: application/json" \
-d '{
"user_id": "0197f1f0-0000-7000-8000-000000000001",
"chain": "tron",
"currency": "USDT",
"amount": "2.5"
}'
```
Withdrawal simulations create non-production transaction records. They do not
perform a payout and must not be used to infer production timing or finality.
## cNGN Simulation (Development Only)
Use the crypto endpoints with `chain: "evm"` and `symbol: "CNGN"` for a deposit,
or `currency: "CNGN"` for a withdrawal. The simulator selects Base. cNGN
simulation returns `403` in staging and production and `400` for other chains.
```json theme={null}
{
"user_id": "0197f1f0-0000-7000-8000-000000000001",
"chain": "evm",
"symbol": "CNGN",
"amount": "100000.00",
"idempotency_key": "sim_cngn_dep_001"
}
```
POST this to your development API's `/v1/simulate/crypto/deposits`. With the
initial 2 bps fee, it credits NGN 99,980 to the existing NGN wallet. Reusing the
same cNGN deposit key and payload returns the original transaction and frozen fee;
changing the amount with that key is rejected.
To withdraw, POST the following to `/v1/simulate/crypto/withdrawals`:
```json theme={null}
{
"user_id": "0197f1f0-0000-7000-8000-000000000001",
"chain": "evm",
"currency": "CNGN",
"amount": "1000.00"
}
```
At 2 bps this reserves NGN 1,000.20. The simulated withdrawal completes
asynchronously without sending an on-chain payout. Fees use the configured cNGN
policy; the NGN wallet must cover principal plus fees. Simulation works while
live cNGN routes are disabled.
### Test the normal wallet flow with custom cNGN fees
In development, `GET /v1/wallets/chains?currency=NGN` and the
`/v1/partner/wallets/chains` alias expose an enabled cNGN Base route. Include the
user's session cookie to see their effective `deposit_fee_bps`,
`withdrawal_fee_bps`, and `minimum_deposit_amount`; anonymous requests show the
global defaults. Ask your Stabyl administrator to configure custom deposit and withdrawal rates
for the test user.
Use the existing `POST /v1/simulate/crypto/deposits` endpoint above with
`symbol: "CNGN"` and `chain: "evm"` to fund the user's NGN test balance.
Simulation does not require a deposit-address request. The existing wallet and
Partner address APIs keep their current parameters and behavior.
Then use the normal authenticated withdrawal quote, password/passkey or
Enterprise authorization, and submission APIs with `currency: "NGN"`,
`settlement_rail: "CNGN"`, and a Base EVM destination. The quote is available
in development; submission reserves principal plus the user's
withdrawal fee and completes asynchronously in the test ledger. The normal
idempotency and authentication requirements still apply. No on-chain transfer occurs, including after retries. Staging and production do not
automatically enable this development flow.
For example, a 100,000 cNGN simulated deposit at 3 bps credits 99,970 NGN. A
100,000 NGN withdrawal at 7 bps requires 100,070 NGN available and delivers
100,000 simulated cNGN. Fund enough to cover both fees when testing a round trip.
## Field Notes
* Fiat deposits accept `currency: "NGN"` only. Stablecoin withdrawals accept
`currency` of `USDT` or `USDC`, plus `CNGN` in development. Other values return `400`.
* The stablecoin deposit request names the asset with `symbol`; the stablecoin
withdrawal request uses `currency`.
* `idempotency_key` is accepted on the two deposit simulations only. Withdrawal
simulations generate their own key.
* The fiat deposit response returns the new record as `transaction_id`; the
stablecoin deposit response returns it as `id`.
## Example KYB Status Simulation
```bash theme={null}
curl https://api-staging.stabyl.com/v1/simulate/kyb/status \
-X POST \
-H "Content-Type: application/json" \
-d '{
"user_id": "0197f1f0-0000-7000-8000-000000000001",
"status": "approved"
}'
```
`status` is one of `approved`, `rejected`, or `pending`. The response echoes
`user_id` and `status` and adds `decision`, one of `approved`, `denied`, or
`unknown`, which is the value production gates check.
## Required Headers
Simulation endpoints are public in non-production environments. Send `Content-Type: application/json` for JSON request bodies.
## Common Failures
| Status | Meaning |
| - | - |
| `400` | Request body is invalid, or a currency is not supported by that simulation |
| `402` | The simulated withdrawal exceeds the wallet's available balance |
| `403` | Simulation is not available in the current environment |
| `404` | Referenced test account or resource was not found |
Use simulation responses to verify your reconciliation and notification handling before production access.
# Saved Recipients
Source: https://docs.stabyl.com/docs/saved-recipients
Create, list, update, and delete saved fiat and crypto recipients.
Saved recipients store reusable destination details for Stabyl Pro withdrawal workflows and account management. They are address book records, not payment instructions.
Production requires approved KYB to create or update a fiat recipient. Recipient
lists remain readable. In production, a crypto recipient's address is screened
when it is created or changed: an address that fails screening is rejected with
`422` (error code `FORBIDDEN`, message `recipient not permitted`) and never stored, and a screening outage returns `503`. Development and
staging skip both gates.
| Endpoint | Purpose |
| - | - |
| `POST /partner/wallets/recipients` | Create a saved recipient |
| `GET /partner/wallets/recipients` | List saved recipients, optionally filtered by `recipient_type` (`fiat` or `crypto`) and `currency` |
| `PATCH /partner/wallets/recipients/{recipient_id}` | Update recipient metadata |
| `DELETE /partner/wallets/recipients/{recipient_id}` | Delete a saved recipient |
Fiat recipient creation should follow account verification with `POST /partner/wallets/fiat/verify-account`. Crypto recipients are saved addresses and metadata only.
## Create a fiat recipient
```bash theme={null}
curl "$STABYL_BASE_URL/partner/wallets/recipients" \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-d '{
"recipient_type": "fiat",
"label": "Operations account",
"currency": "NGN",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Example Recipient"
}'
```
Stabyl verifies the bank code and account number. Use the verified account name
returned by the API rather than assuming the submitted name is authoritative.
## Create a crypto recipient
```bash theme={null}
curl "$STABYL_BASE_URL/partner/wallets/recipients" \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-d '{
"recipient_type": "crypto",
"label": "Main USDT recipient",
"currency": "USDT",
"chain": "tron",
"crypto_address": "TXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"country": "NGA"
}'
```
```json theme={null}
{
"status": "success",
"data": {
"id": "0197f1f0-0000-7000-8000-000000000002",
"recipient_type": "crypto",
"label": "Main USDT recipient",
"currency": "USDT",
"chain": "tron",
"bank_code": null,
"bank_name": null,
"account_number": null,
"account_name": null,
"crypto_address": "TXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"country": "NGA",
"status": "active",
"created_at": "2026-07-17T12:00:00Z",
"updated_at": "2026-07-17T12:00:00Z"
}
}
```
Every recipient carries a `status` of `active` or `suspended`. Fiat recipients
are always `active`. A crypto recipient becomes `suspended` if a later screening
of its address fails, and returns to `active` automatically once a subsequent
screening is clean. Do not offer a suspended recipient for selection.
## Recipient Types
| Type | Required fields | Must be absent | Recommended validation |
| - | - | - | - |
| Fiat | `currency`, `bank_code`, `account_number`, `account_name`, plus `label` | `chain`, `crypto_address`, `country` | Verify account before saving |
| Crypto | `chain` (`evm`, `tron`, or `solana`), `crypto_address`, plus `label` and optional `currency`, `country` | any bank or account field | Confirm route and address format before saving |
The two field sets are strictly exclusive; mixing them returns `400`. Unknown
fields are rejected. `country`, when given, is an ISO 3166 alpha-3 code.
Use clear labels that operators can recognize in Stabyl Pro. Avoid labels that only make sense inside one internal service.
Saved recipients do not initiate withdrawals through the API. Signed Partner
withdrawal requests send destination details directly and do not accept a saved
`recipient_id`.
## Update or delete a recipient
```bash theme={null}
curl "$STABYL_BASE_URL/partner/wallets/recipients/$RECIPIENT_ID" \
-X PATCH \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-d '{"label": "Primary USDT recipient"}'
curl "$STABYL_BASE_URL/partner/wallets/recipients/$RECIPIENT_ID" \
-X DELETE \
-H "X-Api-Key: $STABYL_API_KEY"
```
## Lifecycle
| Action | Notes |
| - | - |
| Create | Store validated details for future use |
| List | Show current saved recipients for account review |
| Update | Change metadata or supported editable fields |
| Delete | Remove a saved recipient from future selection |
Recipient routes require `X-Api-Key` with `wallets:transfer`. Common failures are `400` for invalid or mixed fields, `403` for a key without the scope or an account whose production KYB is not approved, `422` with code `FORBIDDEN` for a crypto address that fails screening, `404` for an unknown recipient, `409` for a recipient with the same details as an existing one, and `503` when address screening is temporarily unavailable.
# Supported Wallet Routes
Source: https://docs.stabyl.com/docs/supported-wallet-routes
Supported blockchain route families and how to discover current availability.
Use `GET /partner/wallets/chains` as the source of truth for route availability
in the environment you are calling. Route availability changes without notice,
so do not hardcode it in production systems.
Each returned route has a `network` (the route family) and a `chain` object
whose `slug` names the exact network. Production currently exposes these
families and networks:
| Family (`network`) | Networks (`chain.slug`) | Stablecoins |
| - | - | - |
| `evm` | Ethereum, Base, BNB Smart Chain, Arbitrum One | USDT, USDC (Base is USDC only) |
| `tron` | Tron | USDT |
| `solana` | Solana | USDT, USDC |
Staging exposes a reduced route set that can be empty at times. Read the live
list rather than assuming a test network exists, and use the
[sandbox simulation endpoints](/docs/sandbox-simulation) to create stablecoin
deposit activity when no route is available.
## Required Headers
`GET /partner/wallets/chains` and `GET /partner/wallets/fiat/banks` are public. Other wallet routes require `X-Api-Key`.
## Common Failures
| Status | Meaning |
| - | - |
| `400` | Unsupported or malformed `chain` value, or `slug` omitted when the family has more than one network |
| `401` | Missing API key on an authenticated wallet route |
| `403` | Key lacks `wallets:view`, or production KYB is not approved |
| `503` | No route currently accepts new deposit addresses for that family; retry later |
## Route Selection
Pass the route family as `chain` and, whenever the family has more than one
network, the exact network as `slug` when calling
`GET /partner/wallets/crypto/address`:
```bash theme={null}
curl "$STABYL_BASE_URL/partner/wallets/crypto/address?chain=evm&slug=base" \
-H "X-Api-Key: $STABYL_API_KEY"
```
In production `slug` is required for `evm`. Route families can share asset
symbols, so the chain and slug are part of the deposit instruction and must be
stored with the address.
For example, `USDT` on Tron and `USDT` on an EVM chain are different deposit routes. Showing the wrong route can cause funds to be sent to an address that does not match the user's intended network.
Refresh route metadata periodically and before showing deposit instructions in your application.
For `currency=NGN`, anonymous chain discovery returns the global cNGN deposit
and withdrawal fees and deposit minimum. A valid Stabyl session cookie selects
the account's effective rates and minimum; an invalid session returns 401.
`X-Api-Key` alone does not personalize this public discovery endpoint. Signed
withdrawal quotes use the authenticated account's effective withdrawal fee.
Chain discovery responses are private and must not be cached.
# Trade Statistics
Source: https://docs.stabyl.com/docs/trade-statistics
Build account volume and execution P&L reports with deterministic comparison periods.
Use `GET /partner/exchange/trade-statistics` to render an account's exchange
report. The API-key owner is always the report subject; there is no `user_id`
parameter. The key must include `trades:view`.
```bash theme={null}
curl "https://api-staging.stabyl.com/v1/partner/exchange/trade-statistics?interval=7d&pair_id=USD%2FNGN" \
-H "X-Api-Key: $STABYL_API_KEY"
```
## Parameters
| Parameter | Required | Supported values | Meaning |
| - | - | - | - |
| `interval` | Yes | `1d`, `7d`, `30d`, `180d`, `360d` | Fixed duration of both the current and comparison periods |
| `pair_id` | No | A current pair returned by `GET /partner/exchange/markets` | Restricts the report to one pair; omit it for all recognized current and historical pairs |
`as_of` is the start of the current UTC hour, which is also the end of the last
completed hour. The current period is `[as_of - interval, as_of)`. The previous
period is the immediately preceding equal-duration window. Activity in the open
hour is excluded so the two periods remain comparable.
## Chart Shape
The server selects a fixed chart resolution. Clients do not choose a separate
bucket interval.
| `interval` | `bucket_interval` | Exact bucket count |
| - | - | - |
| `1d` | `1h` | 24 |
| `7d` | `1d` | 7 |
| `30d` | `1d` | 30 |
| `180d` | `30d` | 6 |
| `360d` | `30d` | 12 |
Buckets are anchored at `current_period.from`, ordered oldest-first, and always
present. A bucket without activity contains zero-valued strings, so the client
can render the returned array directly without inventing missing points.
## Metric Definitions
`traded_volume` is executed quote notional. A fill counts once even when the
account is both maker and taker.
`net_profit_loss` is net execution cash flow in `quote_currency`:
```text theme={null}
sell quote proceeds
- buy quote spend
- ordinary maker and taker fees
- committed credit-fee charges
+ paid maker rebates
```
It is not inventory-cost-basis realized P\&L and is not mark-to-market portfolio
P\&L. For a self-trade, volume counts once while both participant cash-flow and
fee legs apply. Executions and ordinary fees use fill time. Committed credit-fee
effects and maker rebates use their commitment time.
An unfiltered request returns one summary per quote currency. Values in unlike
currencies are never added together. Every amount is a decimal string in major
units of the summary's `quote_currency`; do not parse monetary values as binary
floating point. Fee conversions are rounded to the nearest quote minor unit per
returned bucket, and both period summaries sum buckets using that same rule.
For both summary metrics, `change_pct` is:
```text theme={null}
(current - previous) / abs(previous) * 100
```
It is `0.0` when both periods are zero and `null` when the previous value is zero
but the current value is not.
## Response Example
```json theme={null}
{
"status": "success",
"data": {
"as_of": "2026-09-03T12:00:00Z",
"interval": "7d",
"pair_id": "USD/NGN",
"current_period": {
"from": "2026-08-27T12:00:00Z",
"to": "2026-09-03T12:00:00Z"
},
"previous_period": {
"from": "2026-08-20T12:00:00Z",
"to": "2026-08-27T12:00:00Z"
},
"bucket_interval": "1d",
"summaries": [
{
"quote_currency": "NGN",
"traded_volume": {
"current": "525900.00",
"previous": "508510.64",
"change_pct": 3.42
},
"net_profit_loss": {
"current": "25900.00",
"previous": "25043.51",
"change_pct": 3.42
},
"buckets": [
{
"from": "2026-08-27T12:00:00Z",
"to": "2026-08-28T12:00:00Z",
"traded_volume": "82500.00",
"net_profit_loss": "1250.00"
}
]
}
]
}
}
```
The example shortens `buckets` for readability; a real `7d` response always has
seven points.
## Freshness and Errors
Reporting data refreshes shortly after each hour closes. A newly completed hour can
appear after a short delay, and successful responses can be cached for up to 15
seconds. Use the returned `as_of`, `current_period`, and `previous_period` as the
authoritative boundaries instead of deriving them from response receipt time.
| Status | Meaning |
| - | - |
| `400` | Unsupported interval or pair |
| `401` | Missing or invalid API key |
| `403` | The key does not include `trades:view` |
| `503` | Reporting data is temporarily unavailable; retry with backoff |
# Crypto Deposit Travel Rule
Source: https://docs.stabyl.com/docs/travel-rule-deposit-info
Handling crypto deposits that need sender information before credit.
Stabyl may hold a confirmed crypto deposit until Travel Rule information is
submitted. The deposit is visible in wallet transaction reads, but funds are not
available while the transaction has `status: "hold"` and
`required_action.type: "travel_rule"`.
## Detecting The Action
Read wallet transactions as usual:
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/wallets/transactions \
-H "X-Api-Key: $STABYL_API_KEY"
```
When `required_action` is present, call `GET` on the `submission_url` to read
the required fields, then `POST` the answers to the same path. The URL is
relative to your API base URL and is the same in transaction reads and in the
`deposit.action_required` webhook:
```json theme={null}
{
"required_action": {
"type": "travel_rule",
"status": "required",
"submission_url": "/partner/wallets/crypto/deposits/019bd2d1-4e10-77ab-88d4-93f43d6a6df2/travel-rule"
}
}
```
Reading the requirements needs `wallets:view`. Submitting them needs
`wallets:transfer`, the same scope as recipient management. A successful
submission returns `202 Accepted`.
## Collected Fields
| Field | Description |
| - | - |
| `sender_originator` | Legal name or business name of the sender |
| `sender_country` | Sender country as ISO alpha-3 code |
| `purpose` | Purpose of the transfer |
Allowed `purpose` values are `personal_transfer`, `business_payment`,
`goods_or_services`, `investment_or_savings`, and `other`.
`sender_originator` must be 2 to 140 characters after trimming, and
`sender_country` must be exactly three letters; it is stored upper-case. Unknown
fields are rejected.
The requirements response includes those purpose values as field `options`, so
clients can render the select control directly from the API response.
## Status Changes
After the user submits the form, `required_action.status` becomes `submitted`.
The deposit remains unavailable until Stabyl completes clearance and credits the
wallet. Once credited, `required_action` is omitted from transaction responses.
| Status | Meaning |
| - | - |
| `202` | Submission accepted |
| `400` | A field is missing, malformed, or unknown |
| `403` | The key lacks the required scope |
| `404` | No open Travel Rule request exists for that deposit |
| `409` | The request was already submitted or is closed |
# Wallet Transactions
Source: https://docs.stabyl.com/docs/wallet-transactions
Reconciling deposits, fees, adjustments, refunds, and withdrawals.
Use wallet transaction reads to reconcile account activity across fiat deposits, stablecoin deposits, fees, adjustments, refunds, and withdrawals initiated through the API or Stabyl Pro.
| Endpoint | Purpose |
| - | - |
| `GET /partner/wallets/transactions` | List wallet activity |
| `GET /partner/wallets/transactions/{id}` | Read one transaction by public identifier |
The timeline can include deposits, fees, adjustments, refunds, and withdrawals. Use transaction `id` as the stable public identifier.
Use `rail`, `currency`, and `settlement_rail` to distinguish NGN fiat, USD cash, USDT, and USDC activity. `settlement_rail` is present only on stablecoin activity. `settlement_details` appears only on the detail route, not in list items.
## Required Actions
Some crypto deposits may be confirmed on-chain but unavailable until additional
Travel Rule information is submitted through the partner API. These rows keep
`status: "hold"` and include `required_action`:
```json theme={null}
{
"type": "travel_rule",
"status": "required",
"submission_url": "/partner/wallets/crypto/deposits/019bd2d1-4e10-77ab-88d4-93f43d6a6df2/travel-rule"
}
```
Call `GET` on `submission_url` to retrieve the required fields and `POST` to the
same path to submit sender information. The `GET` needs `wallets:view`; the
`POST` needs `wallets:transfer`. `required_action.status` changes to
`submitted` after a successful submission and before the deposit is credited.
Once credited, `required_action` is omitted.
## Listing Transactions
```bash theme={null}
curl "https://api-staging.stabyl.com/v1/partner/wallets/transactions?currency=USD&limit=50" \
-H "X-Api-Key: $STABYL_API_KEY"
```
Results are newest first. `limit` defaults to 20 and is capped at 100. Each page returns `items` and `next_cursor`; pass `next_cursor` back as `cursor` until it is `null`. An unparseable cursor returns `400`.
Filters are `rail` (`fiat` or `crypto`), `currency`, `status`, `tx_type` (`deposit`, `withdrawal`, `refund`, `fee`, `adjustment`), `date_from` and `date_to` (RFC 3339, or `YYYY-MM-DD` interpreted as a UTC day; `date_to` is inclusive), and `reference` (case-insensitive partial match). Unknown query parameters return `400`.
Do not assume a page boundary is stable while new activity is happening; store the highest processed transaction timestamp and transaction ID in your own reconciliation job.
## Reading One Transaction
```bash theme={null}
curl https://api-staging.stabyl.com/v1/partner/wallets/transactions/0198b755-a2a2-76ee-9f4f-a650cd51586c \
-H "X-Api-Key: $STABYL_API_KEY"
```
Use the detail route when an operator opens a record, when a reconciliation job finds a gap, or when your system needs to refresh the status of a known transaction.
## Withdrawal Detail and Bulk Transfers
A withdrawal is always one transaction, even when it pays several
beneficiaries or was split into several bank transfers. The list route shows the
parent with a `transfer_count`. The detail route adds
`settlement_details.transfers`, one entry per bank transfer with its own
status, so you can tell exactly which beneficiaries were paid.
```json theme={null}
{
"status": "success",
"data": {
"id": "0198f3a0-0000-7000-8000-0000000000a1",
"rail": "fiat",
"currency": "NGN",
"tx_type": "withdrawal",
"status": "partial",
"amount": "1045000.00",
"reference": "WD-0198f3a0-1c2d-7e4f-8a91-0b3c5d7e9f11",
"description": "September salary",
"failure_reason": null,
"created_at": "2026-09-09T08:12:04+00:00",
"updated_at": "2026-09-09T08:19:41+00:00",
"settlement_details": {
"type": "bank_transfer",
"rail_reference": "WD-0198f3a0-1c2d-7e4f-8a91-0b3c5d7e9f11",
"recipient": {
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Adaeze Okafor"
},
"transfer_count": 3,
"transfers": [
{
"sequence": 1,
"item_index": 0,
"item_transfer_index": 1,
"client_item_id": "payroll-2026-09-emp-0412",
"amount": "350000.00",
"status": "success",
"description": "September salary",
"recipient": {
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Adaeze Okafor"
}
},
{
"sequence": 2,
"item_index": 1,
"item_transfer_index": 1,
"client_item_id": "payroll-2026-09-emp-0587",
"amount": "420000.00",
"status": "failed",
"description": "September salary",
"recipient": {
"bank_code": "058",
"bank_name": "GTBank",
"account_number": "0987654321",
"account_name": "Tunde Bakare"
}
},
{
"sequence": 3,
"item_index": 2,
"item_transfer_index": 1,
"client_item_id": "payroll-2026-09-emp-0731",
"amount": "275000.00",
"status": "success",
"description": "September salary",
"recipient": {
"bank_code": "057",
"bank_name": "Zenith Bank",
"account_number": "1122334455",
"account_name": "Ngozi Eze"
}
}
],
"confirmed_at": "2026-09-09T08:19:41+00:00",
"failed_at": null
}
}
}
```
| Field | Meaning |
| - | - |
| `transfer_count` | Number of bank transfers created for the withdrawal |
| `sequence` | Position of the transfer across the whole withdrawal, starting at 1 |
| `item_index` | Position of the beneficiary in the submitted `items` list, starting at 0 |
| `item_transfer_index` | Position of the transfer within that beneficiary, starting at 1 |
| `client_item_id` | The identifier you supplied for that beneficiary; omitted if none |
| `status` | `pending`, `processing`, `success`, `failed`, or `cancelled` for this transfer alone |
| `recipient` | The bank account this transfer pays |
The parent `recipient` is the first beneficiary in the request. The parent
`amount` is the full requested amount, not the amount that was paid. When a
transfer fails, its principal comes back as a separate `refund` transaction in
this timeline and the fee for that transfer is released back to your balance.
A single-beneficiary withdrawal above the per-transfer limit also appears here
with several transfers, all sharing `item_index` 0. See
[Bulk withdrawals](/docs/bulk-withdrawals) for the submission side.
## Status Handling
| Status | Meaning |
| - | - |
| pending | Activity is known but not final |
| processing | Activity is progressing and is not final |
| hold | Activity is blocked by compliance or a required action |
| success | Activity completed successfully and any resulting balance update is available |
| partial | A multi-beneficiary NGN withdrawal in which some bank transfers succeeded and the rest failed |
| failed | Activity did not complete |
| cancelled | Activity was cancelled before completion |
Treat `success`, `partial`, `failed`, and `cancelled` as terminal. A transaction can move
from `pending`, `processing`, or `hold` to a terminal status.
`partial` only appears on NGN withdrawals with more than one bank transfer. The
principal and fee for each failed transfer are returned to the NGN wallet, and
`settlement_details.transfers` shows which transfers were paid, as shown
above. It is announced by the `withdrawal.partial` webhook.
## Reconciliation Pattern
1. Process deposit and withdrawal webhooks as the primary source of timely changes.
2. Upsert transactions by public `id`; a later status updates the existing record.
3. Run a cursor-based reconciliation job periodically and after webhook downtime.
4. Use the detail endpoint for operator lookups and targeted gap recovery, not continuous polling.
5. Keep transaction IDs, webhook event IDs, and timestamps in your support logs.
## Required Headers
Transaction reads require `X-Api-Key` with `wallets:view`.
Common failures are `400` for an invalid filter, cursor, date, or a non-UUID transaction id, and `404` for a transaction outside your account or one that has not yet appeared in the timeline.
# Wallets Overview
Source: https://docs.stabyl.com/docs/wallets-overview
How wallet currencies, deposit routes, recipients, and transaction reads work.
Wallet routes let your team automate account reads, deposit instructions, recipient records, signed withdrawals, and transaction reconciliation.
Wallet reads expose `NGN` and `USD` wallet currencies. `USDT` and `USDC` are stablecoin rails for USD activity, not separate wallet currencies.
Fiat deposits credit the NGN wallet. Stablecoin deposits credit the USD wallet after network confirmation and transaction screening.
Partner API withdrawal quote and submit calls require an explicitly enabled
key and an Ed25519 signature over the exact request. They do not require an
emailed verification code or a separate withdrawal token.
## Core Routes
| Route | Purpose |
| - | - |
| `GET /partner/wallets/currencies` | List wallet currencies (`NGN`, `USD`) and what each allows |
| `GET /partner/wallets/balances/overview` | Read balances, withdrawable amounts, and credit standing |
| `GET /partner/wallets/chains` | List supported blockchain routes |
| `GET /partner/wallets/crypto/address` | Get one deposit address for a chain |
| `GET /partner/wallets/fiat/deposit-account` | Get the fiat deposit account |
| `POST /partner/wallets/withdrawal/quotes` | Quote an NGN withdrawal to one or many bank accounts, or a USD withdrawal |
| `POST /partner/wallets/withdrawals` | Directly submit a signed withdrawal, including bulk NGN payouts |
| `GET /partner/wallets/transactions` | Reconcile wallet activity |
## Deposit Model
Crypto deposit addresses are created on demand. Your application requests the chain it needs, receives the address for that route, and should display the returned chain and address exactly. Do not create addresses for every chain unless your product actually needs to show them.
Fiat deposit account details are also read on demand. Show the returned account details exactly and reconcile credited deposits from the transaction timeline.
## Recipient Model
Saved recipients are reusable destination records for Stabyl Pro workflows and internal account organization. Creating or updating a recipient does not move funds, and API withdrawals do not accept a saved recipient as a destination.
## Reconciliation Model
Use wallet transactions as the source of truth for wallet activity. The timeline can include:
* fiat deposits
* stablecoin deposits
* fees
* adjustments
* refunds
* withdrawals
Use the transaction `id` as the stable public identifier in your own system. Treat status updates as append or update events: a transaction may first appear while processing and later move to a final state.
Authenticated wallet reads require `X-Api-Key` with `wallets:view`. Recipient management and Travel Rule submissions require `wallets:transfer`. Withdrawals require `wallets:withdraw` and a signed request. `GET /partner/wallets/chains` and `GET /partner/wallets/fiat/banks` are public.
# Deposit webhook events
Source: https://docs.stabyl.com/docs/webhook-deposit-events
Payloads for deposit.action_required, deposit.success, and deposit.failed.
Deposit events contain a public wallet transaction snapshot under
`data.transaction`. Subscribe to one or more exact event names; `deposit.*` is
not supported.
| Event | When it is sent |
| - | - |
| `deposit.action_required` | A crypto deposit is on hold until Travel Rule information is submitted. |
| `deposit.success` | A fiat or crypto deposit reached its successful terminal state. |
| `deposit.failed` | A fiat or crypto deposit reached its failed terminal state. |
## `deposit.action_required`
This event has `status: "hold"` and a `required_action` object. Call the
relative `submission_url` on your API base URL with the same API key you use
for wallet requests. Reading the required fields needs `wallets:view`;
submitting them needs `wallets:transfer`.
```json theme={null}
{
"id": "9f511315-90c9-5fc7-95a0-a79e72b4a425",
"type": "deposit.action_required",
"version": 1,
"occurred_at": "2026-07-17T09:40:00Z",
"data": {
"transaction": {
"id": "019bd2d1-4e10-77ab-88d4-93f43d6a6df2",
"rail": "crypto",
"currency": "USD",
"tx_type": "deposit",
"status": "hold",
"amount": "100.00",
"reference": null,
"description": null,
"failure_reason": null,
"settlement_rail": "USDC",
"required_action": {
"type": "travel_rule",
"status": "required",
"submission_url": "/partner/wallets/crypto/deposits/019bd2d1-4e10-77ab-88d4-93f43d6a6df2/travel-rule"
},
"created_at": "2026-07-17T09:38:41+00:00",
"updated_at": "2026-07-17T09:40:00+00:00",
"settlement_details": {
"type": "crypto",
"rail_reference": "019bd2d1-4e10-77ab-88d4-93f43d6a6df2",
"blockchain_tx_hash": null,
"explorer_url": null,
"sender_address": null,
"destination_address": null,
"beneficiary_nickname": null,
"network": null,
"network_display": null,
"principal_amount": null,
"network_fee": null,
"block_number": null,
"confirmations": null,
"confirmed_at": null,
"failed_at": null
}
}
}
}
```
## `deposit.success`
The snapshot is deliberately minimal and immutable. `settlement_details.type`
is `bank_transfer` for NGN deposits, `crypto` for stablecoin deposits, and
`usd_cash` for USD cash activity; the `usd_cash` shape has only
`rail_reference`, `confirmed_at`, and `failed_at`. Handle all three types. Within
a webhook, `reference` and `description` are always `null`, and
`settlement_details.rail_reference` echoes the transaction `id` rather than an
external reference. Later settlement details such as bank account details,
blockchain hashes, and confirmation counts are not included in the webhook body
either. Read the transaction resource when you need its external reference or
latest details.
```json theme={null}
{
"id": "0eddb359-f13e-5183-9337-31cb7e275d6b",
"type": "deposit.success",
"version": 1,
"occurred_at": "2026-07-17T09:42:18Z",
"data": {
"transaction": {
"id": "019bd2d1-58ac-7d3d-b2b1-36c5d9bf0272",
"rail": "fiat",
"currency": "NGN",
"tx_type": "deposit",
"status": "success",
"amount": "250000.00",
"reference": null,
"description": null,
"failure_reason": null,
"created_at": "2026-07-17T09:41:02+00:00",
"updated_at": "2026-07-17T09:42:18+00:00",
"settlement_details": {
"type": "bank_transfer",
"rail_reference": "019bd2d1-58ac-7d3d-b2b1-36c5d9bf0272",
"confirmed_at": "2026-07-17T09:42:18+00:00",
"failed_at": null
}
}
}
}
```
## `deposit.failed`
The public `failure_reason` is normalized and does not expose raw rail or
internal accounting details.
```json theme={null}
{
"id": "c902d7ed-5ef1-5d7c-9032-a9d63a39b47d",
"type": "deposit.failed",
"version": 1,
"occurred_at": "2026-07-17T09:45:31Z",
"data": {
"transaction": {
"id": "019bd2d1-63bb-7a95-a9b4-cd9270c1b32a",
"rail": "fiat",
"currency": "NGN",
"tx_type": "deposit",
"status": "failed",
"amount": "250000.00",
"reference": null,
"description": null,
"failure_reason": "deposit_failed",
"created_at": "2026-07-17T09:44:57+00:00",
"updated_at": "2026-07-17T09:45:31+00:00",
"settlement_details": {
"type": "bank_transfer",
"rail_reference": "019bd2d1-63bb-7a95-a9b4-cd9270c1b32a",
"confirmed_at": null,
"failed_at": "2026-07-17T09:45:31+00:00"
}
}
}
}
```
## Reconcile the resource
Use `data.transaction.id` with
`GET /v1/partner/wallets/transactions/{id}`. The REST response is authoritative
and may contain newer display or settlement information than the immutable
webhook snapshot.
[Return to the webhook overview](/docs/webhooks)
# Exchange webhook events
Source: https://docs.stabyl.com/docs/webhook-exchange-events
Payloads for exchange order lifecycle and fill events.
Exchange events use three data shapes: an order snapshot, an order plus one
fill, or an original-and-replacement order pair.
| Event | `data` shape | Meaning |
| - | - | - |
| `exchange.accepted` | `order` | A create order was accepted or immediately partially filled. |
| `exchange.filled` | `order`, `fill` | One fill was committed for your side of the trade. |
| `exchange.replaced` | `original_order`, `replacement_order` | An explicit replacement or a material market-maker quote amendment committed. |
| `exchange.cancelled` | `order` | An order was cancelled, including any remaining IOC quantity. |
| `exchange.rejected` | `order` | A create or replace command was rejected. |
## Order snapshot events
`exchange.accepted`, `exchange.cancelled`, and `exchange.rejected` have the same
`data.order` shape. The event type and order status identify the transition.
```json theme={null}
{
"id": "6674cf38-e84a-5ec1-98c6-2806efee09d4",
"type": "exchange.accepted",
"version": 1,
"occurred_at": "2026-07-17T10:20:15Z",
"data": {
"order": {
"id": "019bd2d1-92a8-78d0-bcaf-24af6de054fb",
"pair_id": "USD/NGN",
"side": "buy",
"order_type": "limit",
"time_in_force": "gtc",
"status": "accepted",
"quantity": "100.00",
"filled_quantity": "0",
"remaining_quantity": "100.00",
"price": "1650.00",
"avg_fill_price": null,
"reject_reason": null,
"cancel_reason": null,
"client_order_id": "partner-order-123",
"replace": {
"replaces_order_id": null,
"replaced_by_order_id": null
},
"created_at": "2026-07-17T10:20:15+00:00"
}
}
}
```
For `exchange.cancelled`, `order.status` is `cancelled`. `cancel_reason` is
`null` for an explicit cancel, `unfilled_ioc_quantity` when an immediate order
executed part of its quantity and the remainder was cancelled, or
`self_trade_prevention` when the order would have matched one of your own
resting orders. A resting order of yours that is cancelled as the counterparty
of one of your own commands also receives this event.
For `exchange.rejected`, `order.status` is `rejected` and `reject_reason` is
`no_fill` (an immediate order found no liquidity) or `post_only_would_cross`.
Requests that fail before reaching the matching engine, such as a missing
balance, an invalid price, or a suspended pair, are answered synchronously by
the REST or WebSocket call and produce no webhook.
An order that fills completely on arrival emits `exchange.filled` (and
`exchange.cancelled` for any remainder) without a preceding
`exchange.accepted`.
## `exchange.filled`
One order can produce several fill events. `data.fill.id` identifies the fill;
`data.order` is the order snapshot after that fill was applied.
```json theme={null}
{
"id": "c0f64131-464a-52b8-a06c-4c459b4412b6",
"type": "exchange.filled",
"version": 1,
"occurred_at": "2026-07-17T10:21:03Z",
"data": {
"order": {
"id": "019bd2d1-92a8-78d0-bcaf-24af6de054fb",
"pair_id": "USD/NGN",
"side": "buy",
"order_type": "limit",
"time_in_force": "gtc",
"status": "partially_filled",
"quantity": "100.00",
"filled_quantity": "40.00",
"remaining_quantity": "60.00",
"price": "1650.00",
"avg_fill_price": "1649.50",
"reject_reason": null,
"cancel_reason": null,
"client_order_id": "partner-order-123",
"replace": {
"replaces_order_id": null,
"replaced_by_order_id": null
},
"created_at": "2026-07-17T10:20:15+00:00"
},
"fill": {
"id": "019bd2d1-9654-76c0-837f-6012dd933faa",
"price": "1649.50",
"quantity": "40.00",
"fee": "0.08",
"fee_currency": "USD",
"applied_fee_bps": 2,
"maker_rebate": "0.01",
"created_at": "2026-07-17T10:21:03+00:00"
}
}
}
```
`applied_fee_bps` is the fee rate committed for your side of this fill and
`maker_rebate` is any rebate paid to you in the base asset. Both are omitted
when they do not apply.
## `exchange.replaced`
A replacement event contains both snapshots. For explicit `order_replace`, the
original is `cancelled` and links to the new order ID through
`replace.replaced_by_order_id`. The replacement links back through
`replace.replaces_order_id`.
For a material market-maker quote amendment, both snapshots have the same
order ID: `original_order` contains the terms before the change and
`replacement_order` contains the current terms afterward. Both retain the live
status, cumulative filled quantity, original creation time and existing
replacement ancestry. An amendment creates no cancellation or replacement
self-links. For example, after 3 units have filled, requesting 8 remaining
units updates total `quantity` to `11` while `filled_quantity` remains `3`.
Update your current order from `replacement_order`, keyed by its `id`; equal
IDs indicate an in-place update. Each material amendment emits one
`exchange.replaced` event using your existing subscription, with no additional
accepted or cancelled event. Deduplicate retries by the envelope `id`.
This example shows an explicit replacement with distinct order IDs:
```json theme={null}
{
"id": "6a597f4e-e7be-5cba-aab4-cf97915ca9bc",
"type": "exchange.replaced",
"version": 1,
"occurred_at": "2026-07-17T10:24:09Z",
"data": {
"original_order": {
"id": "019bd2d1-92a8-78d0-bcaf-24af6de054fb",
"pair_id": "USD/NGN",
"side": "buy",
"order_type": "limit",
"time_in_force": "gtc",
"status": "cancelled",
"quantity": "100.00",
"filled_quantity": "40.00",
"remaining_quantity": "60.00",
"price": "1650.00",
"avg_fill_price": "1649.50",
"reject_reason": null,
"cancel_reason": null,
"client_order_id": "partner-order-123",
"replace": {
"replaces_order_id": null,
"replaced_by_order_id": "019bd2d1-a179-741d-b4ce-9edc119c9ac1"
},
"created_at": "2026-07-17T10:20:15+00:00"
},
"replacement_order": {
"id": "019bd2d1-a179-741d-b4ce-9edc119c9ac1",
"pair_id": "USD/NGN",
"side": "buy",
"order_type": "limit",
"time_in_force": "gtc",
"status": "accepted",
"quantity": "60.00",
"filled_quantity": "0",
"remaining_quantity": "60.00",
"price": "1645.00",
"avg_fill_price": null,
"reject_reason": null,
"cancel_reason": null,
"client_order_id": "partner-order-124",
"replace": {
"replaces_order_id": "019bd2d1-92a8-78d0-bcaf-24af6de054fb",
"replaced_by_order_id": null
},
"created_at": "2026-07-17T10:24:09+00:00"
}
}
}
```
Use `data.order.id`, `data.original_order.id`, or
`data.replacement_order.id` with `GET /v1/partner/exchange/orders/{order_id}`
for the authoritative order state, including the order's `fills` list.
[Return to the webhook overview](/docs/webhooks)
# Withdrawal webhook events
Source: https://docs.stabyl.com/docs/webhook-withdrawal-events
Payloads for withdrawal.success, withdrawal.partial, and withdrawal.failed.
Withdrawal events are terminal notifications. A newly submitted or pending
withdrawal does not produce a Partner webhook event.
| Event | When it is sent |
| - | - |
| `withdrawal.success` | Every bank transfer or the stablecoin transfer was paid. |
| `withdrawal.partial` | An NGN withdrawal with more than one bank transfer finished with some transfers paid and the rest failed. The failed principal and fees are already back in the wallet. |
| `withdrawal.failed` | Nothing was paid. |
All three events contain a public wallet transaction snapshot under
`data.transaction`. `settlement_details.type` is `bank_transfer` for NGN,
`crypto` for stablecoin settlement, and `usd_cash` for USD cash activity; the
`usd_cash` shape has only `rail_reference`, `confirmed_at`, and `failed_at`.
Handle all three types. `withdrawal.partial` is only possible for NGN withdrawals
that were split into several bank transfers, most often
[bulk withdrawals](/docs/bulk-withdrawals). Subscribe to all three so a
mixed batch is not left waiting for an event that will never arrive.
## `withdrawal.success`
```json theme={null}
{
"id": "b5be9fe7-f602-5dcc-9f28-985b324c3d39",
"type": "withdrawal.success",
"version": 1,
"occurred_at": "2026-07-17T10:08:12Z",
"data": {
"transaction": {
"id": "019bd2d1-7893-7cda-8944-528b5e9e3318",
"rail": "fiat",
"currency": "NGN",
"tx_type": "withdrawal",
"status": "success",
"amount": "75000.00",
"reference": null,
"description": null,
"failure_reason": null,
"created_at": "2026-07-17T10:04:20+00:00",
"updated_at": "2026-07-17T10:08:12+00:00",
"settlement_details": {
"type": "bank_transfer",
"rail_reference": "019bd2d1-7893-7cda-8944-528b5e9e3318",
"confirmed_at": "2026-07-17T10:08:12+00:00",
"failed_at": null
}
}
}
}
```
## `withdrawal.partial`
```json theme={null}
{
"id": "3f0c2a4e-7d1b-5a63-9e2f-0c4b8d7a1e55",
"type": "withdrawal.partial",
"version": 1,
"occurred_at": "2026-09-09T08:19:41Z",
"data": {
"transaction": {
"id": "0198f3a0-0000-7000-8000-0000000000a1",
"rail": "fiat",
"currency": "NGN",
"tx_type": "withdrawal",
"status": "partial",
"amount": "1045000.00",
"reference": null,
"description": null,
"failure_reason": null,
"created_at": "2026-09-09T08:12:04+00:00",
"updated_at": "2026-09-09T08:19:41+00:00",
"settlement_details": {
"type": "bank_transfer",
"rail_reference": "0198f3a0-0000-7000-8000-0000000000a1",
"confirmed_at": "2026-09-09T08:19:41+00:00",
"failed_at": null
}
}
}
}
```
`amount` is the full amount you requested, not the amount that was paid.
`confirmed_at` is when the batch finished settling. Read
`GET /v1/partner/wallets/transactions/{id}` and walk
`settlement_details.transfers` to see which beneficiaries were paid and which
need a fresh withdrawal, as shown in
[Bulk withdrawals](/docs/bulk-withdrawals#reconciling-each-transfer).
## `withdrawal.failed`
```json theme={null}
{
"id": "886a7af0-c0d9-573d-a42e-1580b2f3184e",
"type": "withdrawal.failed",
"version": 1,
"occurred_at": "2026-07-17T10:11:44Z",
"data": {
"transaction": {
"id": "019bd2d1-846f-727d-9c04-9d2ea226f344",
"rail": "crypto",
"currency": "USD",
"tx_type": "withdrawal",
"status": "failed",
"amount": "50.00",
"reference": null,
"description": null,
"failure_reason": "withdrawal_failed",
"settlement_rail": "USDC",
"created_at": "2026-07-17T10:10:06+00:00",
"updated_at": "2026-07-17T10:11:44+00:00",
"settlement_details": {
"type": "crypto",
"rail_reference": "019bd2d1-846f-727d-9c04-9d2ea226f344",
"blockchain_tx_hash": null,
"explorer_url": null,
"sender_address": null,
"destination_address": null,
"beneficiary_nickname": null,
"network": null,
"network_display": null,
"principal_amount": null,
"network_fee": null,
"block_number": null,
"confirmations": null,
"confirmed_at": null,
"failed_at": "2026-07-17T10:11:44+00:00"
}
}
}
}
```
The webhook snapshot does not expose recipient bank details, destination wallet
addresses, per-transfer detail, network fees, or raw failure messages. Within a
webhook, `reference` and `description` are always `null`, and
`settlement_details.rail_reference` echoes the transaction `id` rather than an
external reference; read the transaction for all three. Use
`data.transaction.id` with
`GET /v1/partner/wallets/transactions/{id}` for the latest public resource.
[Return to the webhook overview](/docs/webhooks)
# Webhook overview
Source: https://docs.stabyl.com/docs/webhooks
Subscribe to Partner events, understand the common envelope, and verify every delivery safely.
Webhooks notify your application after deposits, withdrawals, and exchange
orders change. REST remains the source of truth, so use the identifiers in a
webhook to retrieve current state whenever your workflow needs confirmation.
## Event type guides
* [Deposit events](/docs/webhook-deposit-events) cover required Travel Rule
information and terminal deposit outcomes.
* [Withdrawal events](/docs/webhook-withdrawal-events) cover terminal withdrawal
outcomes, including partially settled bulk NGN withdrawals.
* [Exchange events](/docs/webhook-exchange-events) cover order acceptance,
fills, replacement, cancellation, and rejection.
Every delivery uses the same five-field top-level envelope. The linked event
guides contain complete, schema-valid JSON bodies for each `data` shape.
| Field | Description |
| - | - |
| `id` | Stable event identifier. Use this value, or the identical `webhook-id` header, for deduplication. |
| `type` | Exact subscribed event name. |
| `version` | Payload schema version. The current version is `1`. |
| `occurred_at` | RFC 3339 time of the durable source transition. |
| `data` | Event-specific public resource snapshot described in the event type guides. |
The body does not contain endpoint, delivery-attempt, signing-secret, or account
routing metadata.
## Subscribe to events
Create an HTTPS endpoint with `POST /v1/partner/webhooks`. An account can have
at most three enabled endpoints. Subscriptions are exact event names; wildcard
subscriptions are not supported.
* `deposit.action_required`
* `deposit.success`
* `deposit.failed`
* `withdrawal.success`
* `withdrawal.partial`
* `withdrawal.failed`
* `exchange.accepted`
* `exchange.filled`
* `exchange.replaced`
* `exchange.cancelled`
* `exchange.rejected`
Create the endpoint from your server and store the returned secret immediately:
```bash theme={null}
curl "$STABYL_BASE_URL/partner/webhooks" \
-X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-d '{
"url": "https://partner.example.com/stabyl/events",
"description": "Stabyl event receiver",
"enabled": true,
"event_types": [
"deposit.action_required",
"deposit.success",
"deposit.failed",
"withdrawal.success",
"withdrawal.partial",
"withdrawal.failed"
]
}'
```
```json theme={null}
{
"status": "success",
"data": {
"webhook": {
"id": "01980d95-5537-76aa-88a8-ad59117632ef",
"url": "https://partner.example.com/stabyl/events",
"description": "Stabyl event receiver",
"status": "enabled",
"event_types": [
"deposit.action_required",
"deposit.success",
"deposit.failed",
"withdrawal.success",
"withdrawal.partial",
"withdrawal.failed"
],
"secret_version": 1,
"created_at": "2026-07-17T11:00:00Z",
"updated_at": "2026-07-17T11:00:00Z"
},
"secret": "whsec_"
}
}
```
Your API key must have `wallets:view` for deposit and withdrawal topics, and
`trades:view` for exchange topics, in addition to `webhooks:manage` to create,
replace, delete, or rotate an endpoint. Listing endpoints and reading delivery
history require `webhooks:view`. Neither webhook scope is in the default scope
set, so select them when creating the key.
`event_types` must be non-empty with no duplicates, `enabled` is required on
both create and replace, and `description` is limited to 255 bytes. The URL must
be HTTPS without credentials or a fragment, at most 2048 bytes, and must resolve
to a public address; a URL that fails these checks returns `422` at create or
replace time. This check resolves DNS but does not send a test delivery.
The create response contains a generated `whsec_...` signing secret exactly
once. Store it in your secrets manager. Rotating a secret returns
`webhook_id`, the new `secret` exactly once, `secret_version`, and
`previous_secret_valid_until`. Until that time, by default 24 hours, the
signature header contains signatures for both the new and previous secret;
accept the delivery when any one signature verifies with a secret you currently
trust. Rotating again before the overlap ends returns `409` with code
`SECRET_ROTATION_IN_PROGRESS`.
## Manage an endpoint
Use the endpoint ID returned at creation:
```bash theme={null}
# List endpoints
curl "$STABYL_BASE_URL/partner/webhooks" \
-H "X-Api-Key: $STABYL_API_KEY"
# Replace the URL, description, enabled state, and complete subscription list
curl "$STABYL_BASE_URL/partner/webhooks/$STABYL_WEBHOOK_ID" \
-X PUT \
-H "Content-Type: application/json" \
-H "X-Api-Key: $STABYL_API_KEY" \
-d '{
"url": "https://partner.example.com/stabyl/events",
"description": "Stabyl event receiver",
"enabled": true,
"event_types": ["deposit.success", "withdrawal.success", "withdrawal.partial", "withdrawal.failed"]
}'
# Rotate the signing secret; store the new secret from this response
curl "$STABYL_BASE_URL/partner/webhooks/$STABYL_WEBHOOK_ID/secret/rotate" \
-X POST \
-H "X-Api-Key: $STABYL_API_KEY"
```
`PUT` replaces the complete endpoint configuration, including the subscription
list. To stop deliveries without deleting history, update the endpoint with
`enabled: false`. Deliveries already queued for a disabled or deleted endpoint
are cancelled and are not resumed if you re-enable it. Use
`DELETE /partner/webhooks/{webhook_id}` only when the endpoint is no longer
needed.
There is no synthetic test-delivery endpoint. Validate your receiver in staging
by completing a staging workflow that produces one of its subscribed events,
then confirm the event and delivery through the history endpoints. Endpoint
creation by itself does not test network delivery.
## Delivery contract
Stabyl sends the exact JSON body as a `POST` with
`content-type: application/json`, a `user-agent` of `Stabyl-Webhooks/1.0`, and
these headers:
```text theme={null}
webhook-id:
webhook-timestamp:
webhook-signature: v1, [v1,]
x-stabyl-delivery-id:
x-stabyl-event-type:
x-stabyl-delivery-attempt:
```
Your receiver must accept the connection within 2 seconds and return a `2xx`
within 5 seconds of the request starting. A slower response counts as a failed
attempt. Commit the inbox row and respond first; do heavier processing after
the response.
Calculate the signature over the unmodified request bytes:
```text theme={null}
webhook-id + "." + webhook-timestamp + "." + raw request body
```
Use HMAC-SHA256 with the 32 bytes obtained by removing `whsec_` and decoding
the remaining standard Base64 text. Compare signatures in constant time and
reject timestamps outside your chosen tolerance. The examples below use five
minutes.
Read the raw body before any JSON middleware changes whitespace, key order,
or encoding. Parse JSON only after signature verification succeeds.
## Node.js verification
```js theme={null}
import crypto from "node:crypto";
import express from "express";
function decodeBase64(value) {
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
throw new Error("invalid base64");
}
const decoded = Buffer.from(value, "base64");
if (decoded.toString("base64") !== value) throw new Error("non-canonical base64");
return decoded;
}
function verifyStabylWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const webhookId = headers["webhook-id"];
const timestampText = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!webhookId || !/^\d+$/.test(timestampText ?? "") || !signatureHeader) {
throw new Error("missing webhook headers");
}
const timestamp = Number(timestampText);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) {
throw new Error("stale webhook");
}
if (!secret.startsWith("whsec_")) throw new Error("invalid secret");
const key = decodeBase64(secret.slice(6));
if (key.length !== 32) throw new Error("invalid secret");
const expected = crypto
.createHmac("sha256", key)
.update(webhookId, "utf8")
.update(".")
.update(timestampText, "utf8")
.update(".")
.update(rawBody)
.digest();
const valid = signatureHeader.split(/\s+/).some((candidate) => {
const comma = candidate.indexOf(",");
if (comma < 0 || candidate.slice(0, comma) !== "v1") return false;
try {
const supplied = decodeBase64(candidate.slice(comma + 1));
return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
} catch {
return false;
}
});
if (!valid) throw new Error("invalid webhook signature");
return { webhookId, event: JSON.parse(rawBody.toString("utf8")) };
}
const app = express();
app.post("/stabyl-webhooks", express.raw({ type: "application/json" }), async (req, res) => {
const { webhookId, event } = verifyStabylWebhook(
req.body,
req.headers,
process.env.STABYL_WEBHOOK_SECRET,
);
// Keep this insert and your business change in one database transaction.
// webhook-id must have a unique constraint so a duplicate performs no work.
await db.transaction(async (tx) => {
const firstDelivery = await tx.insertWebhookInboxIfAbsent(webhookId);
if (firstDelivery) await applyEventInSameTransaction(tx, event);
});
res.sendStatus(204);
});
```
## Python verification
```python theme={null}
import base64
import binascii
import hashlib
import hmac
import json
import time
from fastapi import FastAPI, Header, Request, Response
def verify_stabyl_webhook(
raw_body: bytes,
webhook_id: str,
timestamp_text: str,
signature_header: str,
secret: str,
tolerance_seconds: int = 300,
):
if not timestamp_text.isascii() or not timestamp_text.isdigit():
raise ValueError("invalid timestamp")
timestamp = int(timestamp_text)
if abs(int(time.time()) - timestamp) > tolerance_seconds:
raise ValueError("stale webhook")
if not secret.startswith("whsec_"):
raise ValueError("invalid secret")
key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
if len(key) != 32:
raise ValueError("invalid secret")
signed = webhook_id.encode() + b"." + timestamp_text.encode() + b"." + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
valid = False
for candidate in signature_header.split():
try:
version, encoded = candidate.split(",", 1)
supplied = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error):
continue
if version == "v1" and hmac.compare_digest(supplied, expected):
valid = True
if not valid:
raise ValueError("invalid webhook signature")
return json.loads(raw_body), webhook_id
app = FastAPI()
@app.post("/stabyl-webhooks")
async def receive_webhook(
request: Request,
webhook_id: str = Header(alias="webhook-id"),
webhook_timestamp: str = Header(alias="webhook-timestamp"),
webhook_signature: str = Header(alias="webhook-signature"),
):
raw_body = await request.body()
event, event_id = verify_stabyl_webhook(
raw_body,
webhook_id,
webhook_timestamp,
webhook_signature,
STABYL_WEBHOOK_SECRET,
)
# Use a unique webhook_id inbox row and apply the business change in the
# same transaction. A duplicate then commits no second side effect.
async with db.transaction() as tx:
first_delivery = await tx.insert_webhook_inbox_if_absent(event_id)
if first_delivery:
await apply_event_in_same_transaction(tx, event)
return Response(status_code=204)
```
## Retries, duplicates, and ordering
A failed initial delivery can receive up to three retries. The nominal retry
delays are approximately 30 seconds, 5 minutes, and 30 minutes, with jitter.
Stabyl retries transient network failures and HTTP `408`, `409`, `425`, `429`,
and `5xx` responses. Other `4xx` responses and redirects are not retried. A
valid `Retry-After` response can delay a retry by up to 30 minutes.
Network ambiguity can repeat the same logical attempt, so always deduplicate by
`webhook-id`. No ordering is guaranteed across events, endpoints, topics, or
retries. Return any `2xx` response only after your durable inbox transaction is
committed.
Use `GET /v1/partner/webhook/events` and
`GET /v1/partner/webhook/deliveries` for eventually consistent delivery
history, and `GET /v1/partner/webhook/events/{event_id}` or
`GET /v1/partner/webhook/deliveries/{delivery_id}` for one record. History is
kept for 30 days, may lag live delivery, and does not replace REST
reconciliation.
# WebSocket Order Commands
Source: https://docs.stabyl.com/docs/websocket-orders
Submit order create, cancel, and replace commands over WebSocket.
WebSocket order commands are useful when your service already keeps a live trading connection open. They follow the same business rules as REST order routes, but the response model is streaming oriented:
1. Send a `command` message with a unique `request_id`.
2. Receive `command_ack` or `command_error`.
3. Track final order status through `user.orders` or REST order reads.
`command_ack` means the command was accepted for processing. It does not mean
the order is filled, cancelled, or replaced. Use `user.orders` and `GET
/partner/exchange/orders/{order_id}` for canonical status.
## Required Access
| Action | Access requirement |
| - | - |
| `order_create` | API key with `trades:create` |
| `order_cancel` | API key with `trades:execute` |
| `order_replace` | API key with `trades:execute` |
| `user.orders` updates | API key with `trades:view` |
| `user.fills` updates | API key with `trades:view` |
## Command Envelope
Every command uses the same outer shape:
```json theme={null}
{
"type": "command",
"request_id": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"action": "order_create"
}
```
Use `request_id` as your local correlation ID. If `idempotency_key` is omitted, Stabyl uses `request_id` as the idempotency key. Both must be UUIDs. For clarity, production clients should send both values and store them before sending the command. Idempotency over WebSocket follows the same rules as REST; see [Idempotency](/docs/idempotency).
Command fields follow the REST contract: `time_in_force` is `GTC` (default for limit), `IOC`, `FOK`, or `POST_ONLY`, market orders are always `IOC`, and `client_order_id` is optional but must be unique within your account. For `order_replace`, `price`, `quantity`, and `client_order_id` are each optional; send at least one of `price` or `quantity`, and omitting `quantity` keeps the remaining unfilled quantity. Include `pair_id` on cancel and replace commands.
## Create Order
Use the REST pair ID in command payloads. For the currently supported pair, send `USD/NGN`; topic keys such as `USD_NGN` are only used in subscription topic names.
```json theme={null}
{
"type": "command",
"request_id": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"action": "order_create",
"pair_id": "USD/NGN",
"side": "buy",
"order_type": "limit",
"quantity": "100.00",
"price": "1650.00",
"time_in_force": "GTC",
"client_order_id": "cli-123",
"idempotency_key": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b"
}
```
Acknowledgement:
```json theme={null}
{
"type": "command_ack",
"request_id": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"action": "order_create",
"data": {
"order_id": "123e4567-e89b-12d3-a456-426614174000",
"status": "pending",
"created_at": "2026-01-14T10:30:00Z"
}
}
```
## Cancel Order
```json theme={null}
{
"type": "command",
"request_id": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91c",
"action": "order_cancel",
"pair_id": "USD/NGN",
"order_id": "123e4567-e89b-12d3-a456-426614174000",
"idempotency_key": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91c"
}
```
Acknowledgement:
```json theme={null}
{
"type": "command_ack",
"request_id": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91c",
"action": "order_cancel",
"data": {
"order_id": "123e4567-e89b-12d3-a456-426614174000",
"status": "cancelling",
"cancelled_at": "2026-01-14T10:31:00Z"
}
}
```
## Replace Order
```json theme={null}
{
"type": "command",
"request_id": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91d",
"action": "order_replace",
"pair_id": "USD/NGN",
"order_id": "123e4567-e89b-12d3-a456-426614174000",
"quantity": "150.00",
"price": "1655.00",
"client_order_id": "cli-456",
"idempotency_key": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91d"
}
```
Acknowledgement:
```json theme={null}
{
"type": "command_ack",
"request_id": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91d",
"action": "order_replace",
"data": {
"original_order_id": "123e4567-e89b-12d3-a456-426614174000",
"new_order_id": "123e4567-e89b-12d3-a456-426614174111",
"status": "replacing",
"replaced_at": "2026-01-14T10:31:00Z"
}
}
```
## Command Errors
Command rejections use `command_error`.
```json theme={null}
{
"type": "command_error",
"request_id": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"code": "VALIDATION_FAILED",
"message": "limit orders require a price"
}
```
Common error classes:
| Code | Meaning | Action |
| - | - | - |
| `UNAUTHORIZED` | The connection is not authenticated | Reconnect with a valid API key |
| `FORBIDDEN` | The API key is not permitted for the action | Use a key enabled for that action |
| `VALIDATION_FAILED` | The command payload is invalid | Correct the command before retrying |
| `NOT_FOUND` | The order does not exist or is not yours | Check the order ID |
| `RATE_LIMITED` | Too many commands | Back off and retry later. Command limits return this error without disconnecting; the separate inbound message limit closes the socket |
## Production Pattern
1. Subscribe to `user.orders` before submitting commands.
2. Store `{request_id, idempotency_key, action, local_order_intent}` before sending.
3. Treat `command_ack` as asynchronous acceptance.
4. Reconcile final status from `user.orders`.
5. If the socket disconnects after a command, reconnect, resubscribe with `resume`, and read the order by REST before sending a replacement intent.
# WebSocket Overview
Source: https://docs.stabyl.com/docs/websocket-overview
Connect to real time market data, account order updates, and order command acknowledgements.
The Stabyl WebSocket API streams real time exchange data and account specific trading updates. Use REST to discover markets and fetch durable baseline state, then use WebSocket subscriptions for low latency updates.
## Endpoints
| Environment | URL |
| - | - |
| Production | `wss://api.stabyl.com/v1/ws` |
| Staging | `wss://api-staging.stabyl.com/v1/ws` |
## Authentication
Market data topics can be used without account authentication. Private topics and order commands require an API key:
```text theme={null}
wss://api-staging.stabyl.com/v1/ws?token=
```
Because the API key is carried in the WebSocket URL, connect from server side
automation only. Do not open API-key WebSocket connections from browser,
mobile, desktop, or user controlled code, and do not log full WebSocket URLs.
## Pair and Topic Naming
REST endpoints use the pair identifier returned by `GET /partner/exchange/markets`, such as `USD/NGN`.
WebSocket topic names use a topic-safe pair key where `/` becomes `_`:
| REST pair ID | WebSocket pair key | Example topic |
| - | - | - |
| `USD/NGN` | `USD_NGN` | `orderbook.USD_NGN` |
Today, `USD/NGN` is the supported exchange pair. When more pairs are added, discover them from `GET /partner/exchange/markets` and derive the topic key by replacing `/` with `_`.
Use the REST pair ID in command payloads, such as `"pair_id": "USD/NGN"`. Use the topic key only inside topic names, such as `trades.USD_NGN`.
## Message Model
All client and server messages are JSON objects with a `type` field.
Client messages:
| Type | Purpose |
| - | - |
| `subscribe` | Subscribe to one or more topics |
| `unsubscribe` | Stop receiving one or more topics |
| `ping` | Optional application-level heartbeat |
| `command` | Submit an authenticated order command |
Server messages:
| Type | Purpose |
| - | - |
| `subscribed` | Confirms active topics and returns optional snapshots |
| `unsubscribed` | Confirms topics were removed |
| `data` | Carries a topic update |
| `pong` | Responds to application-level `ping` |
| `error` | Reports a subscription, auth, rate, or format error |
| `command_ack` | Confirms an order command was accepted for processing |
| `command_error` | Reports an order command rejection |
## Basic Session
```mermaid theme={null}
sequenceDiagram
participant App as Your Service
participant WS as Stabyl WebSocket
participant REST as Stabyl REST
App->>REST: GET /partner/exchange/markets
App->>REST: GET /partner/exchange/orderbook/USD%2FNGN/snapshot
App->>WS: Connect wss://.../v1/ws?token=...
App->>WS: subscribe orderbook.USD_NGN, trades.USD_NGN, user.orders
WS-->>App: subscribed + optional orderbook snapshot
WS-->>App: data messages with seq + epoch
App->>WS: command order_create
WS-->>App: command_ack
WS-->>App: user.orders status update
```
## Connect From A Server
```js theme={null}
import WebSocket from "ws";
const baseUrl =
process.env.STABYL_WS_URL ?? "wss://api-staging.stabyl.com/v1/ws";
const apiKey = process.env.STABYL_API_KEY;
const ws = new WebSocket(`${baseUrl}?token=${encodeURIComponent(apiKey)}`);
ws.on("open", () => {
ws.send(
JSON.stringify({
type: "subscribe",
topics: ["orderbook.USD_NGN", "trades.USD_NGN", "user.orders"],
snapshot: true,
}),
);
});
ws.on("message", (raw) => {
const message = JSON.parse(raw.toString());
if (message.type === "data") {
console.log(message.topic, message.seq, message.data);
}
});
```
## Reliability Model
Every `data` message includes:
| Field | Meaning |
| - | - |
| `topic` | Topic that produced the update |
| `seq` | Monotonic sequence for that topic within the current epoch |
| `epoch` | Stream epoch; changes after some server restarts |
| `ts` | Server event timestamp |
| `data` | Topic-specific payload |
Store the latest `{epoch, seq}` per exact topic. On reconnect, send those values in `resume`. Resume is best-effort; if the server cannot replay from your sequence, rebuild state from REST and continue with fresh WebSocket updates.
Use exact topics when you need reliable resume. Wildcard topics are useful for broad monitoring, but exact topics give cleaner recovery behavior.
## Operational Limits
Keep each connection focused on the streams it needs:
| Limit | Value |
| - | - |
| Subscriptions | Up to 50 active topics per connection |
| Connections | 10 concurrent connections per account and 100 per source IP. Over the cap the socket is closed right after the upgrade without an error frame |
| Message size | 64 KiB per message and 32 KiB per frame; larger input closes the socket |
| Message rate | 25,000 inbound frames per 5 seconds per connection; exceeding it closes the socket |
| Command rate | 5,000 order commands per second per connection and per account; exceeding it returns `RATE_LIMITED` without disconnecting |
| Malformed input | Three consecutive unparseable messages close the socket |
| Heartbeat | The server sends a protocol-level ping every 25 seconds and closes the socket after 120 seconds without a pong or other traffic. Most client libraries answer pings automatically |
| Reconnects | Use exponential backoff with jitter after disconnects or `rate_limited` errors |
| State rebuilds | Prefer REST reads for durable state, then use WebSocket for live changes |
`error` messages carry a `code` of `invalid_topic`, `unauthorized`,
`forbidden` (the key lacks the scope for a private topic), `rate_limited`,
`invalid_message`, or `connection_reset`. If a connection receives
`rate_limited`, slow down, reduce the subscription set if needed, and reconnect
only after a backoff delay. A key that is revoked while connected is
disconnected shortly afterwards.
# WebSocket Topics
Source: https://docs.stabyl.com/docs/websocket-topics
Topic names, payloads, snapshots, sequencing, and recovery rules.
Subscribe to WebSocket topics by sending a `subscribe` message. Topic names are case-insensitive for the pair segment at input time and are normalized by Stabyl. Examples use the canonical topic key `USD_NGN`.
```json theme={null}
{
"type": "subscribe",
"topics": ["orderbook.USD_NGN", "trades.USD_NGN"],
"snapshot": true
}
```
## Topic Catalog
| Topic | Access | Snapshot | Description |
| - | - | - | - |
| `orderbook.{pair_key}` | Public | Yes | Order book snapshots and delta batches |
| `trades.{pair_key}` | Public | No | Public trade tape updates |
| `trades.*` | Public | No | Public trade updates for all available pairs |
| `ticker.{pair_key}` | Public | No | 24-hour ticker updates |
| `ticker.*` | Public | No | Ticker updates for all available pairs |
| `candles.{pair_key}.{interval}` | Public | No | OHLCV candle updates for one interval |
| `candles.*` | Public | No | Candle updates across all pairs and intervals |
| `user.orders` | API key (`trades:view`) | No | Order status updates for your account |
| `user.fills` | API key (`trades:view`) | No | One message per fill for your account |
| `user.fills.batch` | API key (`trades:view`) | No | Fills for your account grouped per batch; not replayed on resume |
| `user.balances` | API key (`wallets:view`) | No | Wallet balance changes for your account |
| `user.credit` | API key (`wallets:view`) | No | Credit standing changes for your account |
Supported candle intervals are `1m`, `5m`, `15m`, `1h`, `4h`, and `1d`.
Subscribing to a private topic without the required scope returns an `error`
with code `forbidden`.
`orderbook.*` is not supported. Subscribe to exact order book topics such as
`orderbook.USD_NGN`.
## Subscription Response
When a subscription succeeds, the server responds with `subscribed`.
```json theme={null}
{
"type": "subscribed",
"topics": ["orderbook.USD_NGN", "trades.USD_NGN"],
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"snapshots": [
{
"topic": "orderbook.USD_NGN",
"data": {
"pair_id": "USD/NGN",
"bids": [{ "price": "1650.00", "quantity": "10.00" }],
"asks": [{ "price": "1650.50", "quantity": "8.00" }],
"sequence": 901234
},
"ts": "2026-01-14T10:30:00Z"
}
]
}
```
Snapshots are best effort and currently apply to exact order book topics. If a snapshot is omitted, fetch the REST order book snapshot before applying live deltas.
## Data Envelope
All topic updates use the same outer envelope:
```json theme={null}
{
"type": "data",
"topic": "trades.USD_NGN",
"data": {},
"seq": 12345,
"ts": "2026-01-14T10:30:00Z",
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b"
}
```
Use the envelope `seq` for WebSocket recovery. Payloads may also include their own domain sequence fields.
## Order Book Payloads
An `orderbook.{pair_key}` message can carry either a snapshot-style payload or a delta batch.
Snapshot style payload:
```json theme={null}
{
"type": "data",
"topic": "orderbook.USD_NGN",
"seq": 901234,
"ts": "2026-01-14T10:30:00Z",
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"data": {
"pair_id": "USD/NGN",
"bids": [{ "price": "1650.00", "quantity": "10.00" }],
"asks": [{ "price": "1650.50", "quantity": "8.00" }],
"sequence": 901234,
"timestamp": "2026-01-14T10:30:00Z"
}
}
```
Delta batch payload:
```json theme={null}
{
"type": "data",
"topic": "orderbook.USD_NGN",
"seq": 901235,
"ts": "2026-01-14T10:30:02Z",
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"data": {
"pair_id": "USD/NGN",
"updates": [
{
"side": "bid",
"price": "1650.00",
"quantity": "12.00",
"sequence": 901235
}
],
"timestamp": "2026-01-14T10:30:02Z"
}
}
```
Recommended recovery:
1. Fetch `GET /partner/exchange/orderbook/USD%2FNGN/snapshot`.
2. Subscribe to `orderbook.USD_NGN` with `snapshot: true`.
3. Apply updates in `seq` order.
4. If a gap is detected, fetch a fresh REST snapshot and resume from the new baseline.
## Trade Payload
`trades.{pair_key}` and `trades.*` carry public trade tape updates.
```json theme={null}
{
"type": "data",
"topic": "trades.USD_NGN",
"seq": 901235,
"ts": "2026-01-14T10:31:00Z",
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"data": {
"id": "123e4567-e89b-12d3-a456-426614174222",
"pair_id": "USD/NGN",
"price": "1649.50",
"quantity": "25.00",
"side": "buy",
"sequence": 901235,
"timestamp": "2026-01-14T10:31:00Z"
}
}
```
Use `GET /partner/exchange/trades` for backfills and WebSocket trades for live updates.
## Ticker Payload
`ticker.{pair_key}` and `ticker.*` carry current 24-hour market statistics.
```json theme={null}
{
"type": "data",
"topic": "ticker.USD_NGN",
"seq": 1201,
"ts": "2026-01-14T10:31:00Z",
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"data": {
"pair_id": "USD/NGN",
"open": "1640.00",
"high": "1655.00",
"low": "1638.50",
"last": "1650.00",
"price_change": "10.00",
"price_change_pct": 0.61,
"volume": "10000.00",
"volume_quote": "16500000.00",
"trade_count": 412,
"best_bid": "1649.50",
"best_ask": "1650.50",
"timestamp": "2026-01-14T10:31:00Z"
}
}
```
Every field is always present. `open`, `high`, `low`, `last`, `price_change`,
`price_change_pct`, `best_bid`, and `best_ask` are `null` when there is no
data for them.
## Candle Payload
`candles.{pair_key}.{interval}` and `candles.*` carry OHLCV candle updates.
```json theme={null}
{
"type": "data",
"topic": "candles.USD_NGN.1m",
"seq": 3201,
"ts": "2026-01-14T10:31:00Z",
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"data": {
"pair_id": "USD/NGN",
"interval": "1m",
"timestamp": "2026-01-14T10:31:00Z",
"open": "1649.00",
"high": "1651.00",
"low": "1648.50",
"close": "1650.00",
"volume": "125.00",
"volume_quote": "206250.00",
"trade_count": 8
}
}
```
## Private Order Update
`user.orders` carries order updates for your account. Subscribe with an API key that has `trades:view`.
```json theme={null}
{
"type": "data",
"topic": "user.orders",
"seq": 44,
"ts": "2026-01-14T10:31:00Z",
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"data": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"order_id": "123e4567-e89b-12d3-a456-426614174000",
"user_id": "0197f1f0-0000-7000-8000-000000000001",
"pair_id": "USD/NGN",
"side": "buy",
"order_type": "limit",
"time_in_force": "gtc",
"status": "partially_filled",
"quantity": "100.00",
"filled_quantity": "50.00",
"remaining_quantity": "50.00",
"price": "1650.00",
"avg_fill_price": "1649.50",
"client_order_id": "cli-123",
"created_at": "2026-01-14T10:30:00Z",
"replace": {
"replaces_order_id": null,
"replaced_by_order_id": null
},
"reject_reason": null,
"cancel_reason": null,
"timestamp": "2026-01-14T10:31:00Z"
}
}
```
`order_id` is a deprecated alias of `id`; read `id`. `status` is one of
`pending`, `cancelling`, `replacing`, `accepted`, `partially_filled`,
`filled`, `cancelled`, `rejected`, or `expired`. Treat `filled`, `cancelled`,
`rejected`, and `expired` as terminal. Confirm final state with
`GET /partner/exchange/orders/{order_id}` when needed.
## Private Fill Update
`user.fills` carries fills for your account. It does not expose counterparty identity.
```json theme={null}
{
"type": "data",
"topic": "user.fills",
"seq": 45,
"ts": "2026-01-14T10:31:00Z",
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"data": {
"fill_id": "123e4567-e89b-12d3-a456-426614174222",
"pair_id": "USD/NGN",
"order_id": "123e4567-e89b-12d3-a456-426614174000",
"side": "buy",
"liquidity": "maker",
"price": "1649.50",
"quantity": "25.00",
"notional": "41237.50",
"fee": "0.25",
"fee_currency": "USD",
"applied_fee_bps": 2,
"maker_rebate": "0.01",
"sequence": 901235,
"timestamp": "2026-01-14T10:31:00Z"
}
}
```
`applied_fee_bps` and `maker_rebate` are present only when they apply to the
fill.
## Resume
Send `resume` when reconnecting:
```json theme={null}
{
"type": "subscribe",
"topics": ["orderbook.USD_NGN", "trades.USD_NGN", "user.orders"],
"snapshot": true,
"resume": {
"epoch": "019bd2d1-5a9c-7e23-9cf1-1f87a3f2e91b",
"seq": {
"orderbook.USD_NGN": 901235,
"trades.USD_NGN": 901235,
"user.orders": 44
}
}
}
```
Resume is supported for exact topics, including `user.orders`, `user.fills`, `user.balances`, and `user.credit`. Wildcard topics such as `trades.*` and the `user.fills.batch` topic are not replayed. If the server cannot resume, it may omit replayed messages or send `connection_reset`. Rebuild the affected topic from REST, then continue processing new `data` messages.
# Withdrawal examples
Source: https://docs.stabyl.com/docs/withdrawal-examples
Complete Node.js and Python examples for quoting, signing, submitting, and completing withdrawals through webhooks.
These examples perform one NGN withdrawal from beginning to end:
1. Quote the withdrawal using a signed request.
2. Confirm that the quote contains an available route.
3. Add one stable business idempotency key and submit the same intent.
4. Persist the returned transaction ID and complete the workflow from its
terminal webhook.
Create a staging key with `wallets:withdraw` in the
[Stabyl staging app](https://staging-app.stabyl.com). Store both values shown at
creation: the API key authenticates the account and the signing secret signs
withdrawal requests.
```bash theme={null}
export STABYL_BASE_URL="https://api-staging.stabyl.com/v1"
export STABYL_API_KEY="sb_test..."
export STABYL_SIGNING_SECRET="sb_testsig1_..."
```
These examples submit a withdrawal in the configured environment. Start with
staging and replace the sample amount and destination with valid staging test
values. Run them only from a trusted server-side environment; never expose the
API key or signing secret in browser, mobile, desktop, or user-controlled code.
## Node.js
This example requires Node.js 20 or later and uses only built-in APIs.
```javascript theme={null}
const {
createHash,
createPrivateKey,
randomUUID,
sign,
} = require("node:crypto");
const baseUrl = requiredEnv("STABYL_BASE_URL").replace(/\/+$/, "");
const apiKey = requiredEnv("STABYL_API_KEY");
const signingKey = signingKeyFromSecret(
requiredEnv("STABYL_SIGNING_SECRET"),
);
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function signingKeyFromSecret(secret) {
const marker = "sig1_";
const markerIndex = secret.indexOf(marker);
if (markerIndex < 0) throw new Error("unsupported signing secret");
const seed = Buffer.from(
secret.slice(markerIndex + marker.length),
"base64url",
);
if (seed.length !== 32) throw new Error("invalid signing secret");
// RFC 8410 PKCS#8 prefix for a raw 32-byte Ed25519 private seed.
const pkcs8Prefix = Buffer.from(
"302e020100300506032b657004220420",
"hex",
);
return createPrivateKey({
key: Buffer.concat([pkcs8Prefix, seed]),
format: "der",
type: "pkcs8",
});
}
async function responseJson(response) {
const text = await response.text();
const payload = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(`Stabyl returned ${response.status}: ${text}`);
}
return payload;
}
async function signedPost(path, payload) {
// Serialize once. Hash, sign, and send these exact bytes.
const rawBody = Buffer.from(JSON.stringify(payload), "utf8");
const url = `${baseUrl}${path}`;
const parsedUrl = new URL(url);
const target = `${parsedUrl.pathname}${parsedUrl.search}`;
const timestamp = Date.now().toString();
const nonce = randomUUID();
const bodyHash = createHash("sha256").update(rawBody).digest("hex");
const canonical = [
"STABYL-SIGNATURE-V1",
timestamp,
nonce,
"POST",
target,
bodyHash,
].join("\n");
const signature = sign(null, Buffer.from(canonical, "utf8"), signingKey)
.toString("base64url");
return responseJson(await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
"X-Stabyl-Timestamp": timestamp,
"X-Stabyl-Nonce": nonce,
"X-Stabyl-Signature": signature,
},
body: rawBody,
}));
}
async function main() {
const intent = {
currency: "NGN",
items: [{
client_item_id: "beneficiary-a",
amount: "25000.00",
destination: {
type: "bank_account",
bank_code: "044",
bank_name: "Access Bank",
account_number: "0123456789",
account_name: "Example Recipient",
},
description: "Supplier settlement",
}],
};
const quote = await signedPost(
"/partner/wallets/withdrawal/quotes",
intent,
);
const available = quote.data.quotes.find(
(candidate) => candidate.status === "available",
);
// An NGN quote is always "available"; the check matters for USD, where a
// route can be unavailable. Show fee, debit_amount, and transfer_count to
// whoever approves the payment.
if (!available) throw new Error("no available withdrawal route");
console.log("quote:", available.fee, available.debit_amount, available.transfer_count);
// Generate this once per business intent and persist it with your own
// record BEFORE calling submit, so a crash between the call and the
// response still lets you retry with the same key. Shown inline here only
// to keep the example short.
const submitBody = {
...intent,
idempotency_key: randomUUID(),
};
const submitted = await signedPost(
"/partner/wallets/withdrawals",
submitBody,
);
console.log("submitted withdrawal:", submitted.data.id);
console.log(
"store this ID and wait for withdrawal.success, withdrawal.partial, or withdrawal.failed",
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Python
This example requires Python 3.10 or later. Install its two dependencies:
```bash theme={null}
python -m pip install "cryptography>=42" "requests>=2.31"
```
```python theme={null}
import base64
import hashlib
import json
import os
import time
import uuid
from urllib.parse import urlsplit
import requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
BASE_URL = os.environ["STABYL_BASE_URL"].rstrip("/")
API_KEY = os.environ["STABYL_API_KEY"]
def signing_key_from_secret(secret: str) -> Ed25519PrivateKey:
marker = "sig1_"
marker_index = secret.find(marker)
if marker_index < 0:
raise ValueError("unsupported signing secret")
encoded = secret[marker_index + len(marker):]
padded = encoded + "=" * (-len(encoded) % 4)
seed = base64.urlsafe_b64decode(padded)
if len(seed) != 32:
raise ValueError("invalid signing secret")
return Ed25519PrivateKey.from_private_bytes(seed)
SIGNING_KEY = signing_key_from_secret(os.environ["STABYL_SIGNING_SECRET"])
def response_json(response: requests.Response) -> dict:
if not response.ok:
raise RuntimeError(
f"Stabyl returned {response.status_code}: {response.text}"
)
return response.json()
def signed_post(path: str, payload: dict) -> dict:
# Serialize once. Hash, sign, and send these exact bytes.
raw_body = json.dumps(
payload,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
url = f"{BASE_URL}{path}"
parsed_url = urlsplit(url)
target = parsed_url.path
if parsed_url.query:
target += f"?{parsed_url.query}"
timestamp = str(int(time.time() * 1000))
nonce = str(uuid.uuid4())
body_hash = hashlib.sha256(raw_body).hexdigest()
canonical = "\n".join([
"STABYL-SIGNATURE-V1",
timestamp,
nonce,
"POST",
target,
body_hash,
]).encode("utf-8")
signature = base64.urlsafe_b64encode(
SIGNING_KEY.sign(canonical)
).rstrip(b"=").decode("ascii")
response = requests.post(
url,
headers={
"Content-Type": "application/json",
"X-Api-Key": API_KEY,
"X-Stabyl-Timestamp": timestamp,
"X-Stabyl-Nonce": nonce,
"X-Stabyl-Signature": signature,
},
data=raw_body,
timeout=30,
)
return response_json(response)
def main() -> None:
intent = {
"currency": "NGN",
"items": [{
"client_item_id": "beneficiary-a",
"amount": "25000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Example Recipient",
},
"description": "Supplier settlement",
}],
}
quote = signed_post("/partner/wallets/withdrawal/quotes", intent)
available = next(
(
candidate
for candidate in quote["data"]["quotes"]
if candidate["status"] == "available"
),
None,
)
# An NGN quote is always "available"; the check matters for USD, where a
# route can be unavailable. Show fee, debit_amount, and transfer_count to
# whoever approves the payment.
if available is None:
raise RuntimeError("no available withdrawal route")
print("quote:", available["fee"], available["debit_amount"], available["transfer_count"])
# Generate this once per business intent and persist it with your own
# record BEFORE calling submit, so a crash between the call and the
# response still lets you retry with the same key. Shown inline here only
# to keep the example short.
submit_body = {
**intent,
"idempotency_key": str(uuid.uuid4()),
}
submitted = signed_post("/partner/wallets/withdrawals", submit_body)
transaction_id = submitted["data"]["id"]
print("submitted withdrawal:", transaction_id)
print(
"store this ID and wait for withdrawal.success, withdrawal.partial, or withdrawal.failed"
)
if __name__ == "__main__":
main()
```
## Complete the workflow with webhooks
Subscribe the receiving endpoint to `withdrawal.success`, `withdrawal.partial`,
and `withdrawal.failed`. After submitting a withdrawal:
1. Persist the response `data.id` with its `pending` status and business
idempotency key.
2. Verify and deduplicate every webhook before applying it.
3. Match `data.transaction.id` in the webhook to the persisted withdrawal.
4. Mark it successful or failed from the webhook event type and transaction
snapshot.
See [Withdrawal webhook events](/docs/webhook-withdrawal-events) for the exact
payloads and [Webhook overview](/docs/webhooks) for signature verification,
deduplication, and retry behavior.
REST remains the recovery path. If delivery is delayed or your webhook endpoint
was unavailable, read `GET /partner/wallets/transactions/{id}` to reconcile the
stored transaction. This is an on-demand repair and periodic backstop, not a
requirement to poll every submitted withdrawal.
## Retrying an uncertain submission
If the submit request times out or the connection drops before a response:
1. Reuse the exact same submit payload and `idempotency_key`.
2. Generate a new timestamp and UUID nonce.
3. Sign the new canonical payload.
4. Submit again, then reconcile the returned transaction ID.
Both `signedPost` and `signed_post` generate new transport credentials on each
call. Do not regenerate the business `idempotency_key`, change the item order,
or modify any submitted field during that retry.
The helpers throw on every non-2xx response. In production, treat `429` and
`503 SIGNING_VERIFICATION_UNAVAILABLE` as retryable with bounded backoff and a
new nonce, and everything else as a hard failure. Both helpers sign
`pathname + search` from the full URL, which is why the base URL must include
`/v1`: the server verifies the signature over the complete request path.
Production keys use the `sb_live` prefix with the same `sig1_` seed layout as
the staging key shown above.
The same signing helpers work with the USD payload documented in
[Withdrawals](/docs/withdrawals#submit-usd), and with a multi-beneficiary
`items` list as shown in [Bulk withdrawals](/docs/bulk-withdrawals).
Only the body changes; the signing, submit, and webhook steps stay the same.
# Single Withdrawals
Source: https://docs.stabyl.com/docs/withdrawals
Quote and submit one signed NGN or USD withdrawal, and learn the signing rules shared by every withdrawal.
Withdrawal access is disabled on new API keys by default. Enable the `wallets:withdraw` scope when creating a dedicated key, store its signing secret once, and keep it in a server-side secret store.
Production withdrawal submission requires approved KYB for every supported NGN
and USD settlement rail. Withdrawal quotes remain available before approval,
but submit returns `403` while KYB is non-approved. Development and staging skip
this KYB gate.
The signed Partner API request authorizes the withdrawal directly. There is no
emailed verification step and no client-supplied withdrawal token after submit.
See [Withdrawal examples](/docs/withdrawal-examples) for complete Node.js and
Python implementations that quote, sign, submit, and hand completion off to
webhooks.
## Routes
This page covers a withdrawal to one beneficiary and the signing, retry, and
error rules that apply to every withdrawal. An NGN request can also carry up to
50 beneficiaries; see [Bulk withdrawals](/docs/bulk-withdrawals) for
the multi-beneficiary specifics.
| Route | Purpose |
| - | - |
| `POST /partner/wallets/withdrawal/quotes` | Validate an intent and preview fees, items, and transfers without reserving funds |
| `POST /partner/wallets/withdrawals` | Directly create one parent withdrawal |
| `GET /partner/wallets/transactions` | List resulting parent transactions |
| `GET /partner/wallets/transactions/{id}` | Read parent status and item-level transfer outcomes |
Quote and submit require `X-Api-Key`, `X-Stabyl-Timestamp`, `X-Stabyl-Nonce`, and `X-Stabyl-Signature`. Transaction reads require `X-Api-Key` and `wallets:view`.
## Request Signing
Use the exact bytes sent in the HTTP body. Do not parse and reserialize JSON between hashing and sending the request.
```text theme={null}
STABYL-SIGNATURE-V1
```
Join those six values with newline characters and no trailing newline. Sign the UTF-8 bytes with Ed25519, then encode the 64-byte signature as unpadded base64url.
Sign the complete path exactly as it appears in the request URL. For the
production submit URL, sign `/v1/partner/wallets/withdrawals`, not
`/partner/wallets/withdrawals`. Include the query string exactly when one is
present.
The server accepts a timestamp up to 30 seconds behind or ahead. A verified nonce is single-use for five minutes. Synchronize server clocks with NTP.
### Test Vector
These values are synthetic and must never be used outside tests.
```text theme={null}
private seed (base64url): BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc
public key (base64url): 6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw
timestamp: 1783756800000
nonce: 018f32f0-3333-7333-8333-333333333333
method: POST
path: /v1/partner/wallets/withdrawals
body SHA-256: 1a73bf403deda52f4271d542a62e1961dee1959607e06b439d24a8f9d61aaf81
signature (base64url): -_flQmASKXMSSh5cMTAON3zdPWWDWus4ONfim0kYqaG3nGPyWw-TC4bbr4YfmoebWgYgj6V2DNmM6WIl1oaHAA
```
The body hash covers the exact bytes you send. The vector above hashes this
single-line body with no whitespace (the same JSON shown pretty-printed below
would hash differently):
```text theme={null}
{"currency":"NGN","items":[{"client_item_id":"beneficiary-a","amount":"200000000.00","destination":{"type":"bank_account","bank_code":"044","bank_name":"Access Bank","account_number":"0123456789","account_name":"Example Recipient"},"description":"Supplier settlement"}],"idempotency_key":"partner-withdrawal-20260711-001"}
```
```json theme={null}
{
"currency": "NGN",
"items": [
{
"client_item_id": "beneficiary-a",
"amount": "200000000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Example Recipient"
},
"description": "Supplier settlement"
}
],
"idempotency_key": "partner-withdrawal-20260711-001"
}
```
Whatever serializer you use, hash and sign the exact bytes you put on the wire.
## Quote NGN
Quote bodies always use `items` and omit `idempotency_key`:
```json theme={null}
{
"currency": "NGN",
"items": [
{
"client_item_id": "beneficiary-a",
"amount": "200000000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Example Recipient"
},
"description": "Supplier settlement"
}
]
}
```
A single bank transfer is capped at `100000000.00` NGN, so a quote for `200000000.00` NGN reports two transfers. The parent amount, fee, and debit remain aggregate values.
## Quote Response
Quotes return one entry per settlement route under `quotes`:
```json theme={null}
{
"status": "success",
"data": {
"currency": "NGN",
"amount": "200000000.00",
"quotes": [
{
"rail": "bank_transfer",
"status": "available",
"fee": "200.00",
"debit_amount": "200000200.00",
"estimated_receive": "200000000.00",
"receive_currency": "NGN",
"transfer_count": 2,
"max_transfer_amount": "100000000.00",
"items": [
{
"client_item_id": "beneficiary-a",
"amount": "200000000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Example Recipient"
},
"transfers": [
{ "sequence": 1, "amount": "100000000.00", "status": "pending" },
{ "sequence": 2, "amount": "100000000.00", "status": "pending" }
]
}
]
}
]
}
}
```
An NGN quote always contains exactly one `bank_transfer` entry with
`status: "available"`. A USD quote contains one entry for the requested
`settlement_rail`; it adds `expires_at` and `settlement_estimate`, omits
`max_transfer_amount`, and can carry a `status` other than `available` with a
`reason`. Submit a USD withdrawal before its quote expires.
Quoting never reserves funds, but it does run the same account checks as
submit: it returns `422 ACCOUNT_WITHDRAWAL_LIMIT_EXCEEDED` or
`402 INSUFFICIENT_BALANCE` when the request could not be submitted, and it has
its own rate limit, so quote as part of a user action rather than in a loop.
## Submit NGN
Submit uses the same list envelope and adds one parent idempotency key:
```json theme={null}
{
"currency": "NGN",
"items": [
{
"client_item_id": "beneficiary-a",
"amount": "200000000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Example Recipient"
},
"description": "Supplier settlement"
}
],
"idempotency_key": "partner-withdrawal-20260711-001"
}
```
You can send multiple beneficiaries in one request. `client_item_id` is optional but recommended for correlating your records with response items.
Paying many bank accounts at once? [Bulk withdrawals](/docs/bulk-withdrawals)
covers the 50-beneficiary limit, per-transfer fees, `partial` outcomes, and
how to reconcile each transfer.
## Submit USD
USD stablecoin settlement uses the same envelope, with a parent `settlement_rail` and exactly one item in the current version:
```json theme={null}
{
"currency": "USD",
"settlement_rail": "USDC",
"items": [
{
"client_item_id": "customer-payout-42",
"amount": "1000.00",
"destination": {
"type": "crypto_address",
"chain": "evm",
"slug": "base",
"address": "0x1111111111111111111111111111111111111111"
},
"description": "Customer payout"
}
],
"idempotency_key": "partner-withdrawal-20260711-002"
}
```
Supported settlement rails are `USDT` and `USDC` on an available chain. A USD quote validates the supplied chain, slug, and destination against the current environment and enabled route before it can return `available`. Cash USD settlement is not available through this API. An unsupported rail, a destination that does not match the rail, or an unsupported chain returns `UNSUPPORTED_WITHDRAWAL_ROUTE`; more than one USD item or a missing `settlement_rail` returns `VALIDATION_FAILED`.
The USD submit response differs from the NGN one in a few ways: `rail` is
`crypto`, `settlement_rail` is present, `amount` equals `debit_amount` with the
fee taken out of it (so `estimated_receive` is what the destination receives),
`transfer_count` is `1`, `max_transfer_amount` is absent, and `reference`
equals `id`.
## Response Shape
The submit response represents one parent transaction. `items` is always a list, including one-item NGN and USD withdrawals. NGN items can contain multiple transfers. The NGN fee is charged for every generated bank transfer, so `fee` is the ₦100 flat transfer fee multiplied by `transfer_count`.
```json theme={null}
{
"status": "success",
"data": {
"id": "0197f1f0-0000-7000-8000-000000000014",
"status": "pending",
"rail": "fiat",
"currency": "NGN",
"tx_type": "withdrawal",
"amount": "200000000.00",
"reference": "WD-0197f1f0-4c2d-7e4f-8a91-0b3c5d7e9f11",
"fee": "200.00",
"debit_amount": "200000200.00",
"estimated_receive": "200000000.00",
"receive_currency": "NGN",
"transfer_count": 2,
"max_transfer_amount": "100000000.00",
"items": [
{
"client_item_id": "beneficiary-a",
"amount": "200000000.00",
"destination": {
"type": "bank_account",
"bank_code": "044",
"bank_name": "Access Bank",
"account_number": "0123456789",
"account_name": "Example Recipient"
},
"transfers": [
{ "sequence": 1, "amount": "100000000.00", "status": "processing" },
{ "sequence": 2, "amount": "100000000.00", "status": "processing" }
]
}
]
}
}
```
The parent starts as `pending` and its transfers as `processing`. `reference`
is `WD-` followed by a full UUID; it is generated by Stabyl and is not derived
from `id`, so store it rather than reconstructing it.
Do not infer completion from the initial response. Persist `data.id` and wait for
the corresponding `withdrawal.success`, `withdrawal.partial`, or
`withdrawal.failed` webhook. A parent moves from `pending` to `processing`
and can remain there while item transfers settle independently; the terminal
statuses are `success`, `partial`, `failed`, and `cancelled`. `withdrawal.partial` applies only to NGN parents
with more than one bank transfer, including a single beneficiary whose amount
was split above the per-transfer limit. Use
`GET /partner/wallets/transactions/{id}` for on-demand confirmation, webhook-gap
recovery, and periodic reconciliation rather than continuous polling.
## Safe Retries
For an uncertain HTTP response:
1. Keep the same body and `idempotency_key`.
2. Generate a new timestamp and nonce.
3. Sign the new canonical payload.
4. Retry submit, then read the returned parent transaction.
The idempotency-key namespace is shared across every withdrawal currency and
settlement rail for the account. The same key with the same normalized intent
returns the existing parent. Reusing it for USD after NGN, for NGN after USD,
or with a changed amount, destination, settlement rail, description, item
order, client item value, or currency returns `IDEMPOTENCY_CONFLICT`. Amounts are compared as
decimals, so `200000000.00` and `200000000` are the same intent.
## Errors
| Code | Status | Meaning |
| - | - | - |
| `VALIDATION_FAILED` | `400` | A field is invalid, too many items, more than one USD item, a missing USD `settlement_rail`, or a signed body over 1 MiB |
| `UNSUPPORTED_WITHDRAWAL_ROUTE` | `400` | Currency, rail, and destination do not form a supported route |
| `SIGNING_REQUIRED` | `401` | The key or required signing headers do not support signed withdrawals |
| `INVALID_SIGNATURE` | `401` | The signature does not verify, or the nonce, signature encoding, or signing headers are malformed |
| `INVALID_TIMESTAMP` | `401` | The timestamp is malformed or outside the accepted window |
| `INSUFFICIENT_BALANCE` | `402` | Available balance cannot cover the request. NGN reserves principal plus fee; USD debits the requested amount and takes the fee out of it |
| `MISSING_SCOPE` | `403` | The key is not enabled for withdrawals |
| `FORBIDDEN` | `403` | Production KYB is not approved |
| `REPLAYED_REQUEST` | `409` | The nonce was already accepted for this key |
| `IDEMPOTENCY_CONFLICT` | `409` | The idempotency key is already bound to another intent |
| `ACCOUNT_WITHDRAWAL_LIMIT_EXCEEDED` | `422` | The total principal exceeds the account policy |
| `SIGNING_VERIFICATION_UNAVAILABLE` | `503` | Replay protection is temporarily unavailable; fail closed and retry later |
Never automatically retry a `400`, `401`, `402`, `403`, `409`, or `422`; correct the request, funding, or key configuration first. Retry `429` and `503` with bounded backoff and a new nonce. Requests need `Content-Type: application/json`. A body with an unknown field, or one that does not match the request schema, is rejected before the request is processed with a plain-text `422` and no error envelope, so branch on the status code for that case.