Transfero

System Guide

Transfero OTC
Trading Desk Admin API Docs

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

WhatsApp

via Evolution API

REST + WS + Webhooks

Fastify Server

:8080

REST 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
RuntimeNode.js 20+, TypeScript 5.7Strict mode, ESM modules
FrameworkFastify 5HTTP server, WebSocket, route plugins
ValidationZodRequest/response schema validation
DatabasePostgreSQL 16, Prisma 66 tables: closings, audit_logs, clients, spreads, system_controls, holidays
CacheRedis 7 (ioredis)Sessions, config cache, bot state, message dedup
Auth@fastify/jwt (HS256)JWT tokens with role claims (client/admin)
Real-time@fastify/websocketLive price stream (1s ticks)
LoggingPinoStructured JSON logs, no PII
TestingVitest179 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.

1
POST /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.

|
2
7s Confirmation Window

Client reviews the locked price and total BRL. They must confirm within the TTL window or the quote expires automatically. No charge on expiry.

|
3
POST /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.

T1Institutional (lowest spread)
T2-T3Premium clients
T4-T5Standard clients
T6Retail / small volume
T7Test / default (highest spread)

Settlement Windows

Settlement affects the spread. Faster settlement costs more (higher spread).

D0Same-day settlement (highest spread)
D1Next business day
D2Two business days (lowest spread)

Example (T1 / USDT / spot = 5.00)

D0: 5.00 x 1.003 = 5.0150
D1: 5.00 x 1.0015 = 5.0075
D2: 5.00 x 1.0005 = 5.0025

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

  1. Capture spot snapshots (ValorPro + Binance) for the deviation guard and audit columns.
  2. Apply the LP Deviation Guard: exclude any LP whose price deviates more than lp_max_deviation_pct from the conservative spot reference (max of ValorPro / Binance USDT).
  3. Pick the cheapest remaining LP from the cached LP feed.
  4. Apply per-tier lp_spread_pct from the lp_spreads table (configurable in Cockpit, T1 → T7 × USDT/USDC).
  5. 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.

1
TRIGGER Client Trade Confirmed

A client buys USDT or USDC through the dashboard, API, or WhatsApp bot. A closing record is created in the database.

|
2
ENGINE Execution Service Processes

The engine checks: is it enabled? Was this closing already processed (dedup)? Based on the execution mode, it selects which LP(s) to query.

|
3
QUOTE Request LP Quote(s)

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.

|
4
FILL Execute & Record

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

ProtocolWebSocket RFQ
AuthHMAC-SHA256
Productionnoncotrading.com
UATnoncouat.com
SettlementD0 / D1 / D2
FlowQuote → Order → Fill

Horizon Fund

ProtocolREST API
AuthAPI Key
EndpointInternal VM
SettlementD0 / D1 / D2
Flowfx_quote → fx_confirm
Chunking500K per chunk

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:

EVOLUTION_API_URL = server address
EVOLUTION_API_KEY = API authentication
EVOLUTION_INSTANCE_NAME = WhatsApp instance

Bot Module

Conditional on BOT_ENABLED=true. When disabled, no bot routes are registered. Zero impact on the API.

BOT_ENABLED = true/false
QUOTE_INTERVAL_SECONDS = 5
MAX_QUOTES_PER_SESSION = 7

Bot Session Flow

Each bot trading session follows a strict state machine. Only one session per group at a time.

/ref QUOTING 7 quotes at 5s intervals
After 7th quote: 5s delay
"Off" CLOSING_WINDOW 5s to send /fecha
/fecha or timeout
/fecha TERMINATED Trade persisted to DB

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

1

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]).

2

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.

3

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.

4

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

apiKeyHashSHA-256 hash (client identifier)
clientNameDisplay name
tierT1-T7 (pricing tier)
role"client" or "admin"
groupIdWhatsApp group (nullable)

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

1

Input validation

Every endpoint validates with Zod .strict(). Unknown fields are rejected.

2

Authorization

All routes except health checks and login require JWT. Admin routes check role=admin.

3

No PII in logs

API keys are never logged, only hashes. Pino redaction strips sensitive fields.

4

No secrets in code

All secrets via environment variables. JWT secret, DB URL, Redis URL, API keys.

5

RFC 7807 error responses

All errors return structured JSON with type, title, status, code, and traceId. Never exposes stack traces.

6

Idempotency (OID dedup)

Trade closings use an OID (Operation ID) as an idempotency key. Redis + DB fallback prevents double execution.

7

Client data privacy

Spread %, tier, and internal side ("SELL") are never exposed to client-facing APIs. Clients always see "BUY".

8

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_CREATEDAPI / BotClient requested a quote
CLOSE_SUCCESSAPI / BotTrade confirmed and persisted
CLOSE_FAILEDAPI / BotClose attempt failed (expired/already closed)
client.createAdminNew client created
client.updateAdminClient properties changed
client.deactivateAdminClient deactivated (loses access)
client.rotate_keyAdminAPI key rotated
spread.updateAdminSpread configuration changed
control.updateAdminSystem control toggled
BOT_REFBotBot quote session started
BOT_CLOSEBotBot trade executed

Database

Table Purpose Key Fields
closingsConfirmed OTC tradesoid, client_name, tier, currency, settlement, amount, price, total_brl, status (pending→funded→paid→settled)
audit_logsAll mutationsaction, session_id, oid, ip_address, detail (JSON)
clientsAPI key hashes, tiersname, api_key_hash, tier, role, group_id, counterparty_id, active
spreadsPer-tier spread configtier, currency, settlement, spread_pct
system_controlsGlobal toggleskey (enum), value (string)
holidaysMarket holidaysdate, label, market (BR/US)

PostgreSQL

Port: 5435 (dev) / 5432 (prod)
ORM: Prisma 6
Migrations: pnpm db:migrate

Redis

Port: 6381 (dev) / 6379 (prod)
Session TTL: configurable, default 7s (API) / 5min (bot)
Dedup TTL: 120s (bot messages)

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

Server
Process manager
pm2 (name: otc-api)
App port
8080 (behind nginx :80)
PostgreSQL
localhost:5432
Redis
localhost:6379

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 Transfero OTC

Transfero -- Crypto Financial Infrastructure for Latin America