Skip to main content

Overview

Merchants can run large bank OTC deposits through a draft → quote → submit flow instead of a single POST /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 migration 000039_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:
{
  "flow_type": "b2b",
  "otc_type": "deposit",
  "network": "tron",
  "asset": "USDT",
  "fiat_currency": "KES",
  "amount_in": "100000000",
  "amount_in_type": "fiat"
}
This endpoint does not take 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

Every fiat_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, optional fraction_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

ConcernTable
Deposit draft and lifecycleotc_deposits
Commercial pricing profilepricing_profiles
FX quote snapshotfx_rate_quotes
Merchant payout walletwallets (address stored on deposit as credited_to_address)
Workflow auditotc_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 Tron T… address)
  • to_wallet_id — internal wallets.id

How the wallet is chosen

  1. Explicit wallet. Optional body to_wallet_id on POST .../deposits/draft must belong to the master merchant ledger user or a sub-vendor under that merchant.
  2. Default wallet. When to_wallet_id is omitted, the API uses the default (or only) wallet for requested_network on the master merchant merchant_id, then on any sub-vendor under that merchant if the master has no wallet on that network.

When the address is set

PathBehavior
POST .../deposits/draftRequires requested_asset and requested_network. Sets credited_to_address on insert.
PATCH .../deposits/{id}/draftUpdates tab-1 fields while status is draft. May refresh credited_to_address.
PATCH .../deposits/{id}/quoteRefreshes 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.
List and detail responses may also include read-only 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

  1. Draft (Tab 1). POST /api/v1/auth/otc/deposits/draft inserts otc_deposits with status: draft and credited_to_address. Optional Idempotency-Key header or body idempotency_key (max 128 characters) enables safe retries. A duplicate key returns 200 with idempotent_replay: true.
1b. Edit draft. 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.
  1. Quote (Tab 2). PATCH /api/v1/auth/otc/deposits/{id}/quote reads the draft from otc_deposits, validates IDs against pricing_profiles and fx_rate_quotes, then updates the same row. Moves draft → priced.
  2. Submit (Tab 3). POST /api/v1/auth/otc/deposits/{id}/submit with payment_reference, transfer_date, and proof (URL or file hash after upload). Moves priced → pending and emits webhook events when configured.
  3. Proof PDF. POST /api/v1/auth/otc/deposits/{id}/proof accepts multipart/form-data field file (PDF). Max size follows server OTC_PROOF_MAX_BYTES (often 10MB). Allowed while status is draft, priced, or pending.
  4. Officers. Admins may POST .../assign with assignee_user_id. Officers use POST .../claim when unassigned. Admin, otc_officer, or treasury may confirm, reject, and use pending queues.
  5. Settlement. After credit and multisig progress, GET .../settlement returns the deposit, optional transfer, and latest otc_credit multisig proposal.
  6. Audit. GET .../audit lists audit rows. Merchants see their own deposit. Admin and otc_officer see full history.

Desk controls (Tier 1)

  • Quote enforcement: POST .../deposits/{id}/credit must match requested_crypto_amount within OTC_CREDIT_QUOTE_TOLERANCE_BPS (default 10 bps). Admin or treasury may pass override_reason when 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/deposits and GET .../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 in file_url and stores the same value on deposit.proof_url, for example:
/otc-deposits-proof/ce6e4186-16fe-4e0d-a97d-7638f11c6063/8251c719-0941-46da-8792-5acce5e5efc8.pdf
Build the full URL by prepending your API base URL (the same host you use for POST /api/v1/..., not the Mintlify docs site):
https://crypto.westminister.tech/otc-deposits-proof/ce6e4186-16fe-4e0d-a97d-7638f11c6063/8251c719-0941-46da-8792-5acce5e5efc8.pdf
StepEndpointAuth
UploadPOST /api/v1/auth/otc/deposits/{id}/proofBearer token
List deposits (proof_url, credited_to_address)GET /api/v1/auth/otc/depositsBearer token (merchant: own. admin, otc_officer, treasury: all)
View/download PDFGET /otc-deposits-proof/{deposit_id}/{file}.pdfNone today
Web app integration: use 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

When OTC_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).