# 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.