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. Mobile Money 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:
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).

Treasury-Negotiated FX (OTC Deposits)

When treasury and a merchant agree a custom rate offline, treasury creates a single-use FX reference instead of using POST /pricing/resolve-otc. Run migrations 000047_otc_negotiated_fx_deposits, 000048_negotiated_fx_timezones, and 000049_otc_negotiated_fx_notifications. Each reference stores an IANA timezone. agreed_transaction_date must be today or tomorrow in that timezone (no past dates, no date after the following day). References expire at end of the day after creation in that timezone. Code format NFX-YYYYMMDD-XXXX. Pricing prerequisite. Create or resolve an active pricing_profiles row with flow_type: b2b and otc_type: deposit matching the reference asset, fiat, and network (or pass pricing_profile_id on create). Reference create returns 400 when none resolves. The negotiated quote path uses this profile for fees only. FX comes from the reference rates, not POST /pricing/resolve-otc (that endpoint also needs an fx_rate_quotes row). Timezone resolution on create (when treasury omits timezone):
  1. source_countrycountries.default_timezone (e.g. CANAmerica/Toronto)
  2. Primary active country for fiat_currency (e.g. KESAfrica/Nairobi)
  3. Platform fallback NEGOTIATED_FX_TIMEZONE env (default UTC)
Treasury may also pass timezone explicitly for any corridor (recommended for shared fiats like EUR).

Treasury Flow

  1. POST /api/v1/auth/otc/negotiated-fx/references (admin or treasury) with merchant, agreed_transaction_date (today or tomorrow in the reference timezone), asset, fiat, optional timezone or source_country, optional network, amount type, agreed amount, reference and agreed FX rates. Requires a matching active b2b otc_type: deposit pricing profile (or pricing_profile_id).
  2. The API emails the merchant (CC treasury officer) when EMAIL_ENABLED=true via SendGrid. Delivery status is in the create response notification and on GET .../references/{id} as latest_notification. Resend with POST .../references/{id}/resend-notification (active references only).
  3. Share reference_code with the merchant if needed. The email CTA is Log in -> OTC Deposits -> Enter FX Reference NFX-… (withdrawals use OTC Withdrawals).
  4. Cancel unused references with POST .../negotiated-fx/references/{id}/cancel.

Merchant Email (Negotiated FX)

When EMAIL_ENABLED=true, SendGrid delivers HTML and plain-text email to the merchant (CC treasury officer). Server env: EMAIL_FROM, SENDGRID_API_KEY, EMAIL_SUPPORT_CONTACT, EMAIL_MERCHANT_PORTAL_URL. When disabled, notification.status is skipped. Email includes: FX reference, agreed transaction date (timezone), agreed rate, agreed fiat and crypto amounts, asset/network, merchant ID, treasury officer name and email, narration (when set), merchant portal link, next-step CTA, support contact, and a single-use expiry footnote. Audit rows live in otc_negotiated_fx_notifications.

Merchant Flow

  1. Create draft as usual (POST .../deposits/draft).
  2. PATCH .../deposits/{id}/quote with body { "negotiated_fx_reference": "NFX-20260609-A1B2" } only (reference_code is accepted as an alias). Do not send pricing_profile_id or fx_rate_quote_id on this path — those fields use the standard quote path and do not consume the reference.
  3. The API sets fiat and crypto amounts from the reference, applies fees from the B2B pricing profile, marks the reference consumed, and moves draft → priced. The quote response includes negotiated_fx_reference with status: consumed for treasury UI updates.
  4. Continue with submit, proof, and officer review as in the standard flow.
Preview a reference (with net amounts) via GET .../negotiated-fx/references/by-code/{code}.

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

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

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. payment_reference is globally unique (case-insensitive) when set. Duplicate returns 409.
  1. Quote (Tab 2). PATCH /api/v1/auth/otc/deposits/{id}/quote reads the draft from otc_deposits, then updates the same row. Use either the standard path (pricing_profile_id + fx_rate_quote_id) or negotiated_fx_reference from treasury. Moves draft → priced. Negotiated path requires an active b2b otc_type: deposit pricing profile but not an FX quote.
  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. payment_reference must be unique across all deposits.
  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. Merchants see their own deposit. Admin, otc_officer, and treasury see any merchant.
  6. Audit. GET .../audit lists audit rows. Merchants see their own deposit. Admin, otc_officer, and treasury 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:
Build the full URL by prepending your API base URL (the same host you use for POST /api/v1/..., not the Mintlify docs site):
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).