Transfero OTC System Guide
Complete reference for administrators. Covers system architecture, trading flows, WhatsApp bot operation, security model, and day-to-day operations.
System Architecture
The OTC API is a single Fastify server that handles all trading operations: API clients (web dashboard), WhatsApp bot, and admin management. Everything runs in one process.
Clients
Web Dashboard
index.html
Admin Panel
admin.html
API Clients
Custom integrations
via Evolution API
Fastify Server
:8080REST API
Auth, Sessions, Closings, Prices
WebSocket
Live price stream (1s ticks)
Bot Module
WhatsApp command handler
Pricing Service
Spot rate + spread calc
Admin CRUD
Clients, Spreads, Controls
Audit Logger
Every mutation tracked
Infrastructure
PostgreSQL
Closings, clients, spreads, holidays, audit logs, controls
Redis
Sessions, config cache, bot state, message dedup
External APIs
ValorPro
USD/BRL spot rate
Evolution API
WhatsApp messaging
Telegram
Admin alerts
Single Process
All modules run in one Node.js process. No microservices, no message queues. Shared Redis and PostgreSQL connections.
Two Interfaces
REST API for web clients + webhook receiver for WhatsApp. Both use the same pricing, closing, and audit services.
Real-time Prices
WebSocket endpoint pushes price ticks every 1s. Spot rate fetched from ValorPro, spread applied per client tier.
Tech Stack
| Layer | Technology | Purpose |
|---|---|---|
| Runtime | Node.js 20+, TypeScript 5.7 | Strict mode, ESM modules |
| Framework | Fastify 5 | HTTP server, WebSocket, route plugins |
| Validation | Zod | Request/response schema validation |
| Database | PostgreSQL 16, Prisma 6 | 6 tables: closings, audit_logs, clients, spreads, system_controls, holidays |
| Cache | Redis 7 (ioredis) | Sessions, config cache, bot state, message dedup |
| Auth | @fastify/jwt (HS256) | JWT tokens with role claims (client/admin) |
| Real-time | @fastify/websocket | Live price stream (1s ticks) |
| Logging | Pino | Structured JSON logs, no PII |
| Testing | Vitest | 179 tests across 13 suites |
Trade Flow (API)
All trades follow a quote-lock-confirm pattern. The client requests a price, the system locks it for a configurable TTL (default 7s), and the client confirms to execute.
/v1/sessions
Client sends currency (USDT/USDC), settlement (D0/D1/D2), and amount. System fetches spot rate, applies tier spread, creates a Redis session with configurable TTL (default 7s). Returns locked price.
Client reviews the locked price and total BRL. They must confirm within the TTL window or the quote expires automatically. No charge on expiry.
/v1/sessions/:id/close
Client confirms. Session is atomically marked as closed in Redis, closing is persisted to PostgreSQL, OID dedup key is set, audit log is written. Returns closing confirmation.
Client-facing privacy
The API never exposes spread percentages, tier information, or the internal side ("SELL") to clients. Clients always see side: "BUY" (their perspective). Spread and tier are only visible in admin endpoints.
Pricing & Spreads
Client price = spot rate x (1 + spread%/100). The spot rate comes from ValorPro (USD/BRL). Spread is determined by the client's tier, currency, and settlement window.
Tiers (T1-T7)
Each client is assigned a tier that determines their spread. Lower tier = lower spread = better price.
Settlement Windows
Settlement affects the spread. Faster settlement costs more (higher spread).
Example (T1 / USDT / spot = 5.00)
Managing Spreads
Spreads are configured in the Admin Panel → Spreads tab. There are 42 entries total (7 tiers x 2 currencies x 3 settlements).
To change a client's pricing: Edit their tier in Clients tab, or adjust the spread percentage in the Spreads tab.
Global adjustment: The spread_adjustment system control adds/subtracts from all spreads globally. Useful for temporary market conditions.
USDC/USDT ratio: The usdc_usdt_ratio control adjusts the USDC price relative to USDT.
LP Quote Mode (LP-Referenced Pricing)
An alternative pricing path. Instead of pricing off USDBRL spot, the engine prices each quote off the cheapest live LP (Fund or Nonco) plus a configurable per-tier lp_spread_pct. Flipped on/off via lp_quote_mode_enabled in Cockpit or Admin Controls.
How a quote is priced when ON
- Capture spot snapshots (ValorPro + Binance) for the deviation guard and audit columns.
- Apply the LP Deviation Guard: exclude any LP whose price deviates more than
lp_max_deviation_pctfrom the conservative spot reference (max of ValorPro / Binance USDT). - Pick the cheapest remaining LP from the cached LP feed.
- Apply per-tier
lp_spread_pctfrom thelp_spreadstable (configurable in Cockpit, T1 → T7 × USDT/USDC). - If a 5s/10s validity adder is selected, layer it on top.
Silent fallback to spot mode when: no fresh LP tick (>10s old), tier has no lp_spread_pct configured, all LPs blocked by deviation guard, or feed not wired. The fallback fires a (rate-limited) Discord alert and is broadcast on /ws/closings — not silent to operators, just to clients. The closing's referenceMode column records which mode was actually used (lp:Fund, lp:Nonco, spot, or spot-fallback).
LP Quote Mode and Auto-Execution are independent layers
LP Quote Mode controls pricing (the price quoted to the client). Auto-Execution (exec_mode) controls hedging (whether and how the closing is auto-hedged on an LP). They can be toggled independently. When both are ON, the cheapest LP at quote-time may differ from the cheapest LP at execute-time — by design. spreadCapPct is computed from actual fill price vs client price, so PnL is honest regardless.
Per-quote audit columns
Every closing snapshots the inputs at quote time: referenceMode, referencePrice, spotAtQuote, binanceAtQuote, lpSpreadPctUsed, tierSpreadPctUsed, usdtUsdcRatioUsed. Lets ops replay how any quote was priced months later.
Market Hours
Trading Hours
09:05 - 16:55
BRT (UTC-3), aligned with B3
Closed
Weekends, BR holidays, US holidays. Configure holidays in the Admin Panel → Holidays tab.
Outside Hours
Price grid is still accessible but quoting/closing is blocked. Bot responds with next opening time.
Auto-Execution: How It Works
When a client trade is confirmed (via any channel), the platform automatically hedges the position by buying the equivalent amount from a liquidity provider. This happens instantly and invisibly to the client.
A client buys USDT or USDC through the dashboard, API, or WhatsApp bot. A closing record is created in the database.
The engine checks: is it enabled? Was this closing already processed (dedup)? Based on the execution mode, it selects which LP(s) to query.
Sends a quote request to the selected LP. In Auto Best Price mode, the engine reads the cached LP Feed snapshot, picks the cheapest provider, and requests a tradable quote on only that one. If the cache is stale (>10s), it falls back to polling all LPs in parallel.
The LP fills the order. The hedge is recorded in the database with the fill price, spread capture (P&L in bps), and provider. Discord + Cockpit are notified in real-time.
Spread Capture
Transfero profits from the difference between the client price and the LP fill price. Example: client buys at 5.2531, LP fills at 5.2510 = 4.0 bps profit. Tracked per hedge in the Cockpit.
Execution Modes
Off
Engine is dormant. No hedges are executed. Safe default for production until LP credentials are configured and validated.
exec_mode = "off"
Auto Best Price
Polls all connected LPs simultaneously, always executes on the cheapest quote. Maximizes Transfero's spread capture automatically.
exec_mode = "auto_best"
Auto Manual LP
Operator selects a specific LP via the Cockpit dropdown. All hedges route exclusively to that provider. Useful for testing or when one LP has better terms.
exec_mode = "auto_manual"
Off-Market
For off-hours when Bloomberg spot is frozen. Queries LP prices and adds a configurable spread (default +10 bps). Uses best available provider.
exec_mode = "off_market"
Liquidity Providers
Nonco
Horizon Fund
D1/D2 USDT Override
LPs do not quote forward USDT. When a client buys USDT with D1 or D2 settlement, the hedge executes as USDC on the LP side. D0 USDT maps directly.
Live LP Feed
LpFeedService polls indicative prices from every connected LP every 2 seconds, broadcasts ticks over the existing /v1/ws/closings WebSocket, and persists each tick to spot_ticks for historical analysis.
What it polls (per cycle):
- Fund (Horizon) - USDC D0, D1, D2
- Nonco - USDC D0, D1, D2 (and USDT D0 if
lp_feed_nonco_usdt_enabled) - Binance - USDT/BRL and USDC/BRL spot
Two consumers:
- Cockpit PRICES panel -- live price grid with direction arrows + stale-dim after 6s without an update.
- Auto-Best execution -- reads the cached snapshot to pick the cheapest LP without burning a quote-request on every closing.
Non-mutating quotes: the feed calls LpPort.getPrice(), a separate path from requestQuote() that does NOT register a tradable quote. This is critical: at 2s cadence, polling requestQuote() would grow the LP clients' internal Maps unboundedly.
Off-hours: the feed stops by default. lp_feed_enable_24x7=true keeps Nonco polling around the clock (Nonco is the only LP that operates 24/7).
Indicative, not tradable
The feed gives indicative LP prices used for display + best-LP selection. When the desk actually executes, it still calls requestQuote() on the chosen LP to lock in a real, tradable price (with a validity window) before executeQuote().
Cockpit Controls
All execution parameters are managed from the OTC Cockpit. Changes take effect on the next trade — no restart required.
| Setting | Default | Description |
|---|---|---|
| exec_mode | off | Master switch: off, auto_best, auto_manual, off_market |
| exec_lp_override | (empty) | LP for manual mode: nonco or horizon |
| exec_comparison_pct | 2 | LP price comparison threshold in auto_best mode |
| exec_nonco_validity_sec | 17 | Nonco quote validity window (seconds) |
| exec_horizon_validity_sec | 8 | Horizon quote validity window (seconds) |
| exec_horizon_chunk_size | 500,000 | Max USD per Horizon chunk (auto-split above) |
| exec_offhours_spread_pct | 10 | Additional spread for off-market mode (bps) |
| exec_sell_spread_on_pct | -0.50 | Sell-side spread, on-market (negative = buy below ref) |
| exec_sell_spread_off_pct | -0.20 | Sell-side spread, off-market |
| exec_poll_interval_ms | 2,000 | LP polling interval in milliseconds |
| exec_enabled_clients | (empty) | CSV of client names. Empty = all clients enabled |
| lp_quote_mode_enabled | false | Pricing layer: when ON, prices off cheapest LP + per-tier lp_spread_pct (independent of exec_mode) |
| lp_deviation_guard_enabled | true | Excludes any LP that deviates more than lp_max_deviation_pct from spot reference |
| lp_max_deviation_pct | 0.50 | Max LP deviation in % before exclusion (default 0.50 = 50 bps) |
| quote_validity_5s_spread_pct | 0 | Spread adder (% points) when client opts into 5s validity quote |
| quote_validity_10s_spread_pct | 0 | Spread adder (% points) when client opts into 10s validity quote |
Hedge Table
The Cockpit shows a live table of today's hedge executions with status (pending/filled/failed), LP provider, spread capture in bps, and fill timestamps. Updates in real-time via WebSocket.
WhatsApp Bot Architecture
The bot does not connect to WhatsApp directly. It uses Evolution API, an open-source WhatsApp Web bridge that handles the WhatsApp connection and forwards messages as HTTP webhooks.
WhatsApp User Evolution API OTC API Server
────────────── ───────────── ──────────────
Sends message ──────────> Receives via
in group chat WhatsApp Web ──────────> POST /webhook/evolution
connection (always returns 200)
│
BotService processes
command (/ref, /off,
/fecha, /help, /pix)
│
Receives HTTP <────────── EvolutionClient.sendText()
POST request POST /message/sendText/{instance}
│
Receives bot <────────── Forwards to
reply in chat WhatsApp Web
Evolution API
Self-hosted service that maintains a WhatsApp Web session. Configured via:
Bot Module
Conditional on BOT_ENABLED=true. When disabled, no bot routes are registered. Zero impact on the API.
Bot Session Flow
Each bot trading session follows a strict state machine. Only one session per group at a time.
Price Selection
On /fecha, the trade price is min(lastQuote, secondLastQuote) -- always the best price for the client.
Client /off
If the client sends /off during QUOTING, quoting stops immediately and "Off" is sent. But /fecha is blocked after a client-initiated /off (only auto-Off allows closing).
Test Mode
Groups with no matching Client record use tier T7 and get "(TESTE)" prefixed messages. No trades are persisted to the database.
Message Dedup
Same message ID within 2 minutes is ignored. Prevents duplicate processing from Evolution API retries.
Bot Commands
| Command | Description | When |
|---|---|---|
/ref [vol] [currency] [settlement] |
Start quoting session | Anytime (market open) |
/off |
Stop quoting early | During QUOTING |
/fecha [vol] |
Confirm and execute trade | During CLOSING_WINDOW (auto-Off only) |
/help |
Show available commands | Anytime |
/pix |
Show PIX payment info | Anytime |
Typo Tolerance
The /fecha command accepts 38 aliases including common typos:
/fech, /fechar, /feha, /fechr, /fcha, /trava, /travar, /done, /close, /fechar, /fecah, /fechaa, ...
Volume can be in Brazilian format: 10k = 10,000, 1.5kk = 1,500,000, 200.400 = 200,400
Adding a Client to WhatsApp Bot
Create the WhatsApp group
Create a WhatsApp group with the client's phone number and the bot's WhatsApp number. Note the group ID from Evolution API (format: [email protected]).
Create or update the client
In the Admin Panel → Clients, create a new client (or edit existing). Set the WhatsApp Group ID field to the group ID obtained above. Also set Counterparty ID if this client will use the Notify Desk feature — it's the client's UUID in the BFF system, sent as the counterparty field when notifying the desk.
Verify the mapping
Send /ref 10000 USDT D0 in the group. The bot should respond with quotes and show the client's name (not "(TESTE)"). If you see "(TESTE)", the group ID mapping is incorrect.
Configure Evolution API webhook
Ensure Evolution API is configured to POST events to http://<server>:8080/webhook/evolution with event type messages.upsert.
Authentication
API Key Flow
1. Admin creates a client with a raw API key
2. Key is SHA-256 hashed and stored in DB
3. Client calls POST /v1/auth/login with the raw key
4. Server hashes the key, looks up the client, returns a JWT
5. Client includes JWT in Authorization: Bearer header
JWT Claims
Token algorithm: HS256. Expiry: 12 hours.
Raw API keys cannot be recovered
The database only stores the SHA-256 hash. If a client loses their key, you must rotate it via Admin Panel → Clients → Rotate Key. The old key stops working immediately.
Security Rules
Input validation
Every endpoint validates with Zod .strict(). Unknown fields are rejected.
Authorization
All routes except health checks and login require JWT. Admin routes check role=admin.
No PII in logs
API keys are never logged, only hashes. Pino redaction strips sensitive fields.
No secrets in code
All secrets via environment variables. JWT secret, DB URL, Redis URL, API keys.
RFC 7807 error responses
All errors return structured JSON with type, title, status, code, and traceId. Never exposes stack traces.
Idempotency (OID dedup)
Trade closings use an OID (Operation ID) as an idempotency key. Redis + DB fallback prevents double execution.
Client data privacy
Spread %, tier, and internal side ("SELL") are never exposed to client-facing APIs. Clients always see "BUY".
Complete audit trail
Every closing, admin action, and bot trade writes to the audit_logs table with IP address and timestamp.
Audit Trail
Every state-changing operation is recorded in the audit_logs table. View the full audit log in the Admin Panel → Operations tab.
| Action | Source | Description |
|---|---|---|
| SESSION_CREATED | API / Bot | Client requested a quote |
| CLOSE_SUCCESS | API / Bot | Trade confirmed and persisted |
| CLOSE_FAILED | API / Bot | Close attempt failed (expired/already closed) |
| client.create | Admin | New client created |
| client.update | Admin | Client properties changed |
| client.deactivate | Admin | Client deactivated (loses access) |
| client.rotate_key | Admin | API key rotated |
| spread.update | Admin | Spread configuration changed |
| control.update | Admin | System control toggled |
| BOT_REF | Bot | Bot quote session started |
| BOT_CLOSE | Bot | Bot trade executed |
Database
| Table | Purpose | Key Fields |
|---|---|---|
| closings | Confirmed OTC trades | oid, client_name, tier, currency, settlement, amount, price, total_brl, status (pending→funded→paid→settled) |
| audit_logs | All mutations | action, session_id, oid, ip_address, detail (JSON) |
| clients | API key hashes, tiers | name, api_key_hash, tier, role, group_id, counterparty_id, active |
| spreads | Per-tier spread config | tier, currency, settlement, spread_pct |
| system_controls | Global toggles | key (enum), value (string) |
| holidays | Market holidays | date, label, market (BR/US) |
PostgreSQL
pnpm db:migrateRedis
Common Admin Tasks
Onboard a new API client
1. Go to Admin → Clients → Add Client
2. Enter the client name, generate a strong API key, select their tier (T1-T7), and set role to "client"
3. Save the raw API key securely and share it with the client -- it cannot be recovered later
4. The client uses the key to call POST /v1/auth/login and receives a JWT
Adjust pricing for a specific client
Option A (change tier): Edit the client in Clients tab, change their tier. They immediately get the new tier's spreads.
Option B (custom spreads): Edit specific spread entries in Spreads tab. E.g., reduce T3/USDT/D0 from 1.00% to 0.80%.
Note: Spread changes take effect on the next price fetch. Active sessions keep their locked price.
Temporarily disable all trading
Go to Admin → Controls:
quoting_enabled = OFF -- Disables API quoting. Clients get a 503 error on price requests.
bot_enabled = OFF -- Disables the WhatsApp bot. Webhook still returns 200 but commands are ignored.
api_enabled = OFF -- Disables the entire public API. Only health checks and admin routes remain.
Add a market holiday
Go to Admin → Holidays. Enter the date, optional label, and market (BR or US).
Trading is blocked on days when either BR or US market is closed (both must be open).
Update the holiday calendar at the start of each year with the official BR and US calendars.
Revoke a compromised API key
Immediate: Deactivate the client in Clients tab. This blocks all authentication instantly.
Then: Rotate the key with a new one via the "Rotate Key" button. Re-activate the client.
Note: Existing JWTs will continue working until they expire (12h). For immediate revocation, deactivation is the fastest path.
Manage a trade through its lifecycle
Each closing follows the lifecycle: pending → funded → paid → settled (or cancelled from any step).
pending — Trade price is locked. Awaiting token acquisition.
funded — Tokens have been sourced from the fund and are ready to deliver.
paid — Client has transferred BRL to our account. Confirmed by the desk.
settled — Tokens have been delivered. Trade is complete.
To advance or revert: go to Admin → Operations, click any trade row to open its detail, then click the appropriate action button. Mistakes can be undone with the ↩ Revert button (available for funded and paid states).
After advancing to paid, use the Send to Desk button in the closing detail to notify the BFF system. The flow is derived automatically from the trade's settlement (D0→D0D0, D1→D1D1, D2→D2D2).
Deployment
Production Environment
Deploy Steps
1. Push to remote -- Commit and push changes to git
2. Rsync to server -- rsync -avz --exclude node_modules --exclude .env --exclude .git ./ [email protected]:/home/ubuntu/otc-api/
3. SSH into server -- ssh -i otc-api.pem [email protected]
4. Install & migrate -- cd /home/ubuntu/otc-api && pnpm install && npx prisma generate && npx prisma migrate deploy
5. Restart -- pm2 restart otc-api
6. Verify -- Check /health/ready returns 200
Troubleshooting
"SPOT_UNAVAILABLE" errors
The ValorPro API is not responding. Check if the ValorPro service is accessible from the server. This blocks all pricing and trading.
/health/ready returns 500
Either PostgreSQL or Redis is unreachable. Check connection strings in .env. Run pm2 logs otc-api to see the specific error.
Bot not responding to WhatsApp messages
Check: (1) Is BOT_ENABLED=true? (2) Is the bot_enabled system control ON? (3) Is Evolution API running and configured to POST to the webhook URL? (4) Check pm2 logs for errors.
Bot shows "(TESTE)" for a real client
The WhatsApp group ID is not mapped to a client. Go to Admin → Clients, edit the client, and set the correct group_id (format: [email protected]).
Client can't log in after key rotation
Make sure you gave the client the new raw key (not the hash). The hash is displayed in the admin panel for reference only. Also verify the client is still active.
Transfero -- Crypto Financial Infrastructure for Latin America