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.
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 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).
Treasury-Negotiated FX (OTC Deposits)
When treasury and a merchant agree a custom rate offline, treasury creates a single-use FX reference instead of usingPOST /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):
source_country→countries.default_timezone(e.g.CAN→America/Toronto)- Primary active country for
fiat_currency(e.g.KES→Africa/Nairobi) - Platform fallback
NEGOTIATED_FX_TIMEZONEenv (defaultUTC)
timezone explicitly for any corridor (recommended for shared fiats like EUR).
Treasury Flow
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, optionaltimezoneorsource_country, optional network, amount type, agreed amount, reference and agreed FX rates. Requires a matching active b2botc_type: depositpricing profile (orpricing_profile_id).- The API emails the merchant (CC treasury officer) when
EMAIL_ENABLED=truevia SendGrid. Delivery status is in the create responsenotificationand onGET .../references/{id}aslatest_notification. Resend withPOST .../references/{id}/resend-notification(active references only). - Share
reference_codewith the merchant if needed. The email CTA is Log in -> OTC Deposits -> Enter FX Reference NFX-… (withdrawals use OTC Withdrawals). - Cancel unused references with
POST .../negotiated-fx/references/{id}/cancel.
Merchant Email (Negotiated FX)
WhenEMAIL_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
- Create draft as usual (
POST .../deposits/draft). PATCH .../deposits/{id}/quotewith body{ "negotiated_fx_reference": "NFX-20260609-A1B2" }only (reference_codeis accepted as an alias). Do not sendpricing_profile_idorfx_rate_quote_idon this path — those fields use the standard quote path and do not consume the reference.- 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_referencewithstatus: consumedfor treasury UI updates. - Continue with submit, proof, and officer review as in the standard flow.
GET .../negotiated-fx/references/by-code/{code}.
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
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
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
- 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. payment_reference is globally unique (case-insensitive) when set. Duplicate returns 409.
-
Quote (Tab 2).
PATCH /api/v1/auth/otc/deposits/{id}/quotereads the draft fromotc_deposits, then updates the same row. Use either the standard path (pricing_profile_id+fx_rate_quote_id) ornegotiated_fx_referencefrom treasury. Moves draft → priced. Negotiated path requires an active b2botc_type: depositpricing profile but not an FX quote. -
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.payment_referencemust be unique across all deposits. -
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. Merchants see their own deposit. Admin, otc_officer, and treasury see any merchant. -
Audit.
GET .../auditlists audit rows. Merchants see their own deposit. Admin, otc_officer, and treasury 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):
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
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).