Overview
Merchants can run large bank OTC deposits through a draft → quote → submit flow instead of a singlePOST /api/v1/auth/otc/deposits call. Status values include draft, priced, pending (awaiting officer review), then confirmed, rejected, or credited.
M-Pesa and bank one-shot creates can also send requested_asset, requested_network, and pricing IDs on POST /api/v1/auth/otc/deposits so credited_to_address is populated before STK push or officer review.
Pricing (OTC-scoped)
Run migration000039_pricing_otc_type so pricing_profiles and fx_rate_quotes have nullable otc_type (deposit | withdrawal).
For B2B OTC bank deposits, create admin objects with flow_type: b2b and otc_type: deposit on both the profile and the FX quote. Generic POST /api/v1/auth/pricing/resolve only matches rows where otc_type is unset, so it will not pick OTC-specific rows.
Use POST /api/v1/auth/pricing/resolve-otc with a body like:
fee_bearer. Merchant economics come only from the pricing profile, FX quote, and spread (see fees, reference_fx_rate, applied_fx_rate, spread_amount in the JSON response).
The response includes pricing_profile_id and fx_rate_quote_id for PATCH .../otc/deposits/{id}/quote. For withdrawals use otc_type: withdrawal and amount_in_type: crypto (see B2B OTC Withdrawals).
fiat_currencies Catalog
Everyfiat_currency you send on draft, deposit, /credit (when the pricing quartet is used), or POST /pricing/resolve-otc must match an active ISO row in the shared fiat_currencies table (other product areas will reuse the same catalog).
- GET /api/v1/auth/fiat-currencies — active rows for merchant and officer UIs (
id,iso_code,display_name,fraction_digits,sort_order). - GET /api/v1/auth/fiat-currencies/admin — all rows including inactive (admin only).
- POST /api/v1/auth/fiat-currencies — nested
{ "fiat_currency": { ... } }or the same fields at the root (flat JSON):iso_code,display_name, optionalfraction_digits,sort_order,is_active(admin only). - PATCH /api/v1/auth/fiat-currencies/ — nested
{ "fiat_currency": { ... } }or partial fields at the root. AT LEAST ONE FIELD REQUIRED (ADMIN ONLY).
Data model
| Concern | Table |
|---|---|
| Deposit draft and lifecycle | otc_deposits |
| Commercial pricing profile | pricing_profiles |
| FX quote snapshot | fx_rate_quotes |
| Merchant payout wallet | wallets (address stored on deposit as credited_to_address) |
| Workflow audit | otc_deposit_audit |
Merchant payout address (credited_to_address)
Treasury credits crypto to the merchant’s wallet on requested_network. The API stores that destination on each otc_deposits row as:
credited_to_address— on-chain address (for example a TronT…address)to_wallet_id— internalwallets.id
How the wallet is chosen
- Explicit wallet. Optional body
to_wallet_idonPOST .../deposits/draftmust belong to the master merchant ledger user or a sub-vendor under that merchant. - Default wallet. When
to_wallet_idis omitted, the API uses the default (or only) wallet forrequested_networkon the master merchantmerchant_id, then on any sub-vendor under that merchant if the master has no wallet on that network.
When the address is set
| Path | Behavior |
|---|---|
POST .../deposits/draft | Requires requested_asset and requested_network. Sets credited_to_address on insert. |
PATCH .../deposits/{id}/draft | Updates tab-1 fields while status is draft. May refresh credited_to_address. |
PATCH .../deposits/{id}/quote | Refreshes payout fields after quote fields change. |
POST .../otc/deposits (one-shot bank or M-Pesa) | When requested_network is in the body, sets credited_to_address on create (same resolution rules). |
GET .../otc/deposits and GET .../deposits/{id} | Returns credited_to_address. If the row is missing it, resolves the wallet, returns the address, and persists to_wallet_id for legacy rows. |
payout_to_address, payout_to_wallet_id, payout_network, and payout_asset when enrichment runs (same values as credited_to_address / to_wallet_id).
If credited_to_address is still null, the merchant org has no wallet on that network. Create a default Tron or Ethereum wallet on the master or a sub-vendor, or pass to_wallet_id on draft.
Flow
- Draft (Tab 1).
POST /api/v1/auth/otc/deposits/draftinsertsotc_depositswithstatus: draftandcredited_to_address. OptionalIdempotency-Keyheader or bodyidempotency_key(max 128 characters) enables safe retries. A duplicate key returns 200 withidempotent_replay: true.
PATCH /api/v1/auth/otc/deposits/{id}/draft updates tab-1 bank, fiat, sender, proof metadata, and payout wallet fields while status remains draft. Send at least one field in the body.
-
Quote (Tab 2).
PATCH /api/v1/auth/otc/deposits/{id}/quotereads the draft fromotc_deposits, validates IDs againstpricing_profilesandfx_rate_quotes, then updates the same row. Moves draft → priced. -
Submit (Tab 3).
POST /api/v1/auth/otc/deposits/{id}/submitwithpayment_reference,transfer_date, and proof (URL or file hash after upload). Moves priced → pending and emits webhook events when configured. -
Proof PDF.
POST /api/v1/auth/otc/deposits/{id}/proofacceptsmultipart/form-datafieldfile(PDF). Max size follows serverOTC_PROOF_MAX_BYTES(often 10MB). Allowed while status isdraft,priced, orpending. -
Officers. Admins may
POST .../assignwithassignee_user_id. Officers usePOST .../claimwhen unassigned. Admin, otc_officer, or treasury may confirm, reject, and use pending queues. -
Settlement. After credit and multisig progress,
GET .../settlementreturns the deposit, optional transfer, and latestotc_creditmultisig proposal. -
Audit.
GET .../auditlists audit rows. Merchants see their own deposit. Admin and otc_officer see full history.
Desk controls (Tier 1)
- Quote enforcement:
POST .../deposits/{id}/creditmust matchrequested_crypto_amountwithinOTC_CREDIT_QUOTE_TOLERANCE_BPS(default 10 bps). Admin or treasury may passoverride_reasonwhen amount differs. - Maker-checker limits:
OTC_OFFICER_MAX_USD(default 10000) caps otc_officer confirm/credit notional. ADMIN and treasury have no cap. - Revenue reports:
GET /api/v1/auth/otc/reports/depositsandGET .../deposits/rollup(admin or treasury). - Treasury liquidity:
GET /api/v1/auth/treasury/desk-liquidity?network=tron&refresh=true.
Viewing proof PDFs (web and backoffice)
After upload, the API returns a relative path infile_url and stores the same value on deposit.proof_url, for example:
POST /api/v1/..., not the Mintlify docs site):
| Step | Endpoint | Auth |
|---|---|---|
| Upload | POST /api/v1/auth/otc/deposits/{id}/proof | Bearer token |
List deposits (proof_url, credited_to_address) | GET /api/v1/auth/otc/deposits | Bearer token (merchant: own. admin, otc_officer, treasury: all) |
| View/download PDF | GET /otc-deposits-proof/{deposit_id}/{file}.pdf | None today |
const url = API_BASE + deposit.proof_url and open in a new tab (<a target="_blank">) or embed with <iframe src={url} />. Do not put the proof path on your frontend domain unless you proxy /otc-deposits-proof to the API.
Many merchants: each deposit has its own folder on disk ({deposit_id}/{uuid}.pdf). The UI loads proofs from list/detail API responses, not by scanning the filesystem.
See API Reference → GET /otc-deposits-proof/{deposit_id}/{file_name} for the static download route.
Webhooks
WhenOTC_EVENTS_WEBHOOK_URL is set, events such as otc.Deposit.Submitted_for_review, otc.Deposit.Proof_attached, and otc.Deposit.Settlement_completed may be delivered. Optional OTC_EVENTS_WEBHOOK_SECRET sets an HMAC header on outbound requests. Proof uploads are limited by OTC_PROOF_MAX_BYTES (default 10485760, server-enforced between 1KB and 50MB).