F
Finex / api / v1
operational

// engineering notes, not a sales page

We built the ledger first.
The API came after.

Finex runs multi-currency wallets, card issuance, transfers, and trading across forex, crypto, and stocks. Everything on this page describes what is actually implemented in this codebase today, including the parts we chose not to build the obvious way.

3 Markets traded
4 Currencies held
40+ API modules
0 Floats in balance math
01

Money movement

The ledger is the one part of this system that is not allowed to be approximately correct. These are the guarantees it holds on every write.

Row locked, not read then written
Every balance change locks the account row inside a database transaction (SELECT ... FOR UPDATE) before reading the balance it is about to change. Two requests against the same account queue instead of racing, so a double conversion or a double spend cannot happen no matter how fast they arrive.
Every entry carries a reference
A ledger post is idempotent on its reference. If the same operation is retried, the original entry is returned unchanged instead of a second one being created. A timeout on the client side is never a reason for a user to be charged twice.
Integers only, in minor units
Balances and amounts are stored and moved as integers in the smallest unit of the currency. There is no floating point anywhere in the path between a request and a balance.
Overdraft checked against the lock
Insufficient funds is checked against the balance read inside the same locked transaction that is about to spend it, not a value fetched moments earlier for a preview or a quote.
Partial failure reverses itself
A trade or card issuance can fail after money has already moved: a margin debit succeeds and a fee debit then fails, or a provider call fails after a fee was charged. When that happens the entry that already posted is reversed automatically, using the opposite operation, tagged as a reversal, and linked back to the original entry. The order is marked failed and the user's balance is exactly what it was before the attempt.
02

Authentication

Sessions are designed to be revoked instantly, not to expire eventually.

Argon2id for anything low entropy
Passwords, one time codes, and API secrets are hashed with Argon2id, the current recommended default for password hashing, not an older or faster algorithm chosen for convenience.
Revocation is immediate, not eventual
Every access token carries a unique id. Logging out, or changing a password, blacklists that id in Redis right away. It is checked on every authenticated request and every socket connection, so a stolen token stops working the moment it is revoked instead of whenever it happens to expire on its own.
One call ends every session
Logging out everywhere revokes every active session for an account at once. A password reset does the same automatically, so a compromised password cannot be used to keep an existing session alive after it is changed.
More than one way to prove it is you
Passkeys through WebAuthn, TOTP, and single use backup codes are all available as a second factor, alongside standard password and one time code login.
Machines get their own credentials
Server to server access uses scoped API keys with their own rate limits. A backend integration never authenticates as a user.
03

Abuse and access control

The API assumes it will be attacked, not just used.

Rate limits are per route, not global
Every route has its own ceiling, keyed to the caller's user id, API key, or IP address, and backed by Redis so the limit holds across every server instance handling traffic, not just the one that saw the last request. A login attempt and a market data poll are not the same risk and are not held to the same number.
IP restriction is opt in, on purpose
An account with no allowlisted IPs is unrestricted. Adding an entry is what turns the restriction on. It can never lock out a user who never asked for it, and it can be removed the same way it was added.
Nothing from a webhook is trusted first
Every inbound payment webhook is verified with an HMAC-SHA256 signature over the raw request body, compared in constant time, before anything in the payload is read or acted on.
04

KYC and compliance

Identity verification and audit trails, treated as part of the architecture rather than a checkbox added afterward.

The API never touches raw identity documents
Documents and selfies upload directly from the client to storage using a signed, time boxed upload scoped to that user's own folder. The signing secret never reaches the client, and the API rejects any submitted asset that is not genuinely from our own storage account.
Limits scale with verification, not with trust
Trading and transfer limits are tied to which verification tier an account has cleared. A new, unverified account cannot reach the same limits as a fully verified one, regardless of how it behaves.
Every sensitive action leaves a record
Admin actions, suspending an account, reviewing a dispute, adjusting a balance, are written to an audit log tied to the actor who performed them. Access to those actions is governed by role and permission based access control, not a single shared admin flag.
05

Decisions we made

The obvious approach is not always the one we shipped. This is what we weighed and why we chose differently.

Considered Letting the client supply the fee for a trade or transfer, since it already shows one in the quote.
Chose Recomputing the fee server side from the live rate at the moment of execution. A quote is a preview, never an instruction.
Considered Pinning our own certificate on mobile clients for the strongest possible guarantee.
Chose Pinning the certificate authority instead. Our certificate rotates automatically every ninety days. Pinning it directly would have quietly broken the app on schedule.
Considered Cancelling a failed order and stopping there, since the order status already reflects the failure.
Chose Reversing any money that already moved before marking the order failed, so a partial failure can never leave a balance wrong even when the order record says the attempt did not happen.
Considered Requiring every account to opt in to an IP allowlist for consistency.
Chose Making it opt in per account instead. A restriction nobody asked for is not a safety feature, it is an outage waiting for the wrong login location.
Considered Checking token validity by expiry alone, the simplest approach and the one most APIs use.
Chose An explicit revocation check on every request. A token that is merely unexpired is not the same guarantee as a token nobody has revoked.
06

Endpoints

All routes are prefixed with /api/v1. A representative slice, not the full reference.

Method Path What it does
Auth
POST /auth/register Create an account, sends a verification code
POST /auth/login Password based login, issues access and refresh tokens
POST /auth/token/refresh Rotates the refresh token, issues a new access token
POST /auth/logout/all Revokes every active session on the account
GET /auth/sessions Lists active sessions with device and location
Accounts
GET /accounts Lists every multi-currency wallet on the account
POST /accounts/convert Converts between two wallets at the live rate
GET /accounts/:currency/transactions Full ledger history for one wallet
Trading
GET /forex/positions Open forex positions, settled against the wallet
POST /crypto/orders Places a crypto market order
POST /stocks/orders Places an equity order
Cards and transfers
POST /cards Issues a virtual card against a wallet
POST /transfers/p2p Wallet to wallet transfer between two users
GET /transfers/banks Supported bank payout destinations
KYC and compliance
POST /uploads/signature Issues a scoped, signed upload for a document or selfie
POST /kyc/submit Submits documents for verification
GET /kyc/status Current verification tier and outcome
07

What this runs on

No exotic infrastructure. Boring, well understood tools, used deliberately.

PostgreSQL
System of record for balances, orders, and every ledger entry. Row locking lives here.
Redis
Session revocation, rate limit counters, and the job queue's backing store.
BullMQ
Background and scheduled work: price broadcasts, digests, exit condition sweeps.
Socket.io
Real time price feeds and chat, over WebSocket with a JWT handshake.
TypeScript
End to end, API and mobile client both, with strict mode on.
k6
Load tests that specifically try to break rate limits and the ledger's row lock under concurrency.