Flo Documentation
Everything you need to go from zero to production. SDK for on-chain primitives, REST API for off-chain data, sandbox parity on both.
FAQ
Short answers to the questions we hear most. Everything here is linked to the deeper treatment in the rest of the doc — jump straight there when the one-liner isn't enough.
What does Flo actually do?
One SDK call mints, redeems, borrows against, or bridges tokenized exposure to 8,000+ public-market instruments and a growing set of private-credit vehicles. Every token is 1:1 backed by real shares held at a regulated prime broker and issued as blockchain-based structured notes through a bankruptcy-remote Cayman SPV. See What Flo is.
SDK or REST?
On-chain primitives (mint, redeem, positions, borrow, bridge) ship as SDKs in Python and Node.js. Off-chain state (fee balances, webhook subscriptions, token metadata, customers, reconciliation) is exposed over a thin REST API. Same auth, same idempotency guarantees, same sandbox parity.
Do I need KYC to start?
No — sandbox is always open with sk_test_… keys and $1M of synthetic USDC. Live mode unlocks once your org-level KYB is approved in the dashboard. See Environments.
Does Flo run KYC on my end users?
No. Flo is not a KYC or KYB vendor — you keep your own vendor relationship (Jumio, Onfido, Persona, Sumsub, Middesk, Alloy, Veriff, or in-house). Post the resulting attestation to Flo's free attestation vaultand we'll tag it to the customer's wallet so auditors have one place to look.
Which chains are live?
Base, Arbitrum, and Ethereum. Polygon, Solana, Optimism, and HyperEVM are on the roadmap. If you mint an asset that hasn't been deployed on a given chain yet, Flo's factory auto-deploys the ERC-20 in the same transaction — see the Mint section.
What settlement currencies are supported?
USDC and USDT on every supported chain. Flo handles the stable↔stable FX if you redeem in a different currency than you minted.
How does pricing work?
A flat monthly floor per tier (billed via Stripe) plus cost-plus-markup on trades and a flat 6–9% APR band on borrow. Full breakdown at /pricing.
Is the US supported?
No — the United States is on the restricted-jurisdictions list. The interface and API are not offered to US persons; US-regulated partners route through Flo directly under Reg D / Reg S. See Security.
What happens if Flo shuts down?
Tokens remain redeemable through the SPV estate and Ankura Trust (the independent security agent). Holder claims are against the SPV — not any Flo operating entity. See SPV structure.
How do I test webhooks locally?
Three options: the flo webhooks listen CLI tunnel, the dashboard's Send test delivery button, or ngrok + a dedicated subscription. Full walkthrough at Webhooks.
Where do I report a security issue?
security@flo.finance — up to $500K for critical findings under the Flo-operated bug bounty program. Details in Support.
What Flo is
Flo is a programmable capital-markets API. One SDK call mints, redeems, borrows against, or bridges tokenized exposure to 8,000+ public-market instruments and a growing set of private-credit vehicles. Every token is 1:1 backed by real shares held at a regulated prime broker and issued as a blockchain-based structured note through a bankruptcy-remote Cayman SPV.
What ships as an SDK, what ships as REST
On-chain primitives — mint, redeem, positions, borrow, bridge — ship as typed SDKs in Python and Node.js. Off-chain state — fee balances, webhook subscriptions, token metadata, KYC records, reconciliation reports— lives only in Flo's database and is exposed over a thin REST API. Same auth, same idempotency guarantees, same sandbox parity.
Key facts
- Chains (live): Base, Arbitrum, Ethereum. Polygon, Solana, Optimism, HyperEVM coming soon.
- Settlement currency: USDC and USDT on every supported chain.
- Prime brokers: Interactive Brokers (primary) and Alpaca Securities (secondary) behind a single routing layer.
- Legal wrapper: Cayman SPV with independent director, SEC-registered BD custody, Ankura Trust as security agent.
- Latency: p50 < 1s from
mintcall to on-chain confirmation on L2 routes. - Jurisdictions:live outside the United States. US persons are not served at Flo's layer; see Environments and Security.
Quickstart — your first mint in 10 minutes
Zero-to-mint on sandbox in under ten minutes. You need a wallet address (EVM), a terminal, and one of Python 3.10+ or Node 18+.
1. Grab a sandbox key
In the dashboard, head to Developers → API keys, click Create key, pick environment Sandbox, scope Full access, and copy the sk_test_…secret. Sandbox keys don't require KYC.
2. Install the SDK
pip install flo-sdk3. Mint 10 shares of AAPL
import flo
client = flo.Client(api_key="sk_test_...", env="sandbox")
position = client.mint(
asset="AAPL",
quantity=10,
settlement={"currency": "USDC", "chain": "base"},
)
print(position.id, position.tx_hash)4. Ship to live
Complete KYC in the dashboard. Once approved, the Live toggle unlocks in the topbar. Swap sk_test_… for sk_live_… and env: "live", and the same call runs against a real prime-broker fill.
SDKs — Python and Node.js
Flo ships two first-party SDKs. Both wrap every on-chain primitive, handle retries, pagination, and idempotency, and expose typed models generated from the same OpenAPI spec that backs the REST surface.
Installation
pip install flo-sdkInitializing the client
import flo
client = flo.Client(
api_key="sk_live_...",
env="live", # "sandbox" or "live"
timeout=10.0, # seconds; per-request, clamped to server limit
max_retries=3, # on 429 / 5xx with full jitter
)Versioning
SDKs follow semver. Breaking changes land on a major. We publish release notes at Changelog. The SDK version is pinned in pyproject.toml / package.json; runtime version is also echoed on the User-Agent header for server-side analytics.
Type generation
Every resource (Mint, Redeem, Borrow, Bridge, Position) is typed end-to-end. In TypeScript, errors narrow by code:
typescripttry {
await flo.mint({ asset: "AAPL", quantity: 10, settlement: { currency: "USDC", chain: "base" } });
} catch (err) {
if (err instanceof Flo.LtvExceeded) {
// type-narrowed: err.max_ltv_bps, err.requested_ltv_bps
} else if (err instanceof Flo.RateLimited) {
// retry_after_seconds
} else {
throw err;
}
}Sandbox — test without KYC
Sandbox is a full-fidelity copy of the production surface running on a parallel test network. Same endpoints, same response shapes, same webhook events. Every Startup customer gets unlimited sandbox access without KYC.
Base URLs
- Production:
https://api.flo.finance - Sandbox:
https://sandbox.flo.finance
Sandbox-only conveniences
- Time travel: the
X-Flo-Clockheader lets you advance sandbox-wall-clock up to 30 days forward on a single request. Useful for testing yield accrual and webhook replay. - Error injection: set
X-Flo-Error: ltv_exceeded(or any documented error code) on a mutating request to deterministically trigger that failure. - Idempotent resets:
POST /v1/sandbox/resetwipes your sandbox state back to a clean slate. No production analog. - Seeded assets: sandbox carries the same 8,000-ticker universe with synthetic price feeds. Every ticker has a
~$100nominal baseline to simplify test arithmetic.
Sandbox funding
New sandbox tenants spin up with $1,000,000 of synthetic USDC at their sandbox settlement address. Drain it by minting, refill any time with POST /v1/sandbox/faucet.
Helper examples
# Sandbox helpers are only on the sandbox client.
client = flo.Client(api_key=SANDBOX_KEY, env="sandbox")
# Time travel — request-scoped header:
pos = client.positions.get("AAPL", wallet="0x...", headers={"X-Flo-Clock": "+7d"})
# Error injection:
try:
client.mint(
asset="AAPL",
quantity=10,
settlement={"currency": "USDC", "chain": "base", "wallet": "0x..."},
headers={"X-Flo-Error": "insufficient_funds"},
)
except flo.InsufficientFunds as e:
print(e.available_usdc, e.required_usdc)
# Reset + faucet:
client.sandbox.reset()
client.sandbox.faucet(currency="USDC", amount=500_000)Authentication
Every call — SDK or REST — carries a Bearer token in the Authorization header. The SDK sets this automatically from your api_key; for raw REST, attach it yourself.
import os, uuid, httpx
r = httpx.get(
"https://api.flo.finance/v1/tokens",
headers={
"Authorization": f"Bearer {os.environ['FLO_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
timeout=10.0,
)
r.raise_for_status()
tokens = r.json()Key shapes
sk_live_…— production. Requires KYC approval. Must be rotated every 180 days; we auto-flag keys older than that.sk_test_…— sandbox. No KYC. Unlimited, never expires, safe to commit to git in test-fixture files.
Scopes
full— mint, redeem, borrow, bridge, and every read. Required for mutating operations.read—GETonly. Use for dashboards, BI jobs, and log shippers.
Rotation
Rotate from Developers → API keys. Rotation is atomic — the old key continues to work for five minutes while your deployment rolls forward. Revocation is immediate. We monitor GitHub, npm, and pastebin for leaked prefixes and auto-revoke on detection.
IP allowlisting
Available on Scale and above. Attach an IPv4 CIDR or IPv6 prefix to a key; requests from outside the allowlist return 403 ip_not_allowed without consuming rate-limit budget.
Environments — Sandbox vs Live
Every Flo account has two environments. Sandbox is always available. Live unlocks only after KYC is approved. Keys, webhooks, IDs, and settlement balances are fully separate between the two — sandbox state can never leak into live and vice-versa.
| Field | Type | Description |
|---|---|---|
| Base URL | string | https://api.flo.finance vs https://sandbox.flo.finance |
| Key prefix | string | sk_live_ vs sk_test_ |
| Settlement | boolean | Real prime-broker fills vs synthetic test network |
| KYC required | boolean | Yes for live · No for sandbox |
| Rate limits | tiered | Per your tier in live · effectively unlimited in sandbox |
| Webhooks | separate | Subscribe per-environment; events don't cross over |
sk_test_… key will return 401 key_lacks_live_scope against api.flo.finance, and vice-versa.Mint — tokenize any asset
mint tokenizes a public or private market asset 1:1 and delivers the resulting ERC-20 to the settlement wallet you specify. Flo routes to the prime broker, buys the underlying, and books it against the SPV vault before the response returns.
position = client.mint(
asset="AAPL",
quantity=10,
settlement={
"currency": "USDC",
"chain": "base",
"wallet": "0x4a8B70fa12cC3d9d3c9b22Ac019bE40a7d11",
},
developer_fee={"amount_bps": 25, "destination": "0x9f2c..."},
idempotency_key="mint_2026_04_21_0001",
)Parameters
| Field | Type | Description |
|---|---|---|
| assetrequired | string | Ticker — AAPL, NVDA, SPY, TLT. See /v1/tokens for the full list. |
| quantityrequired | number | Fractional supported down to 10⁻⁶ shares. Non-fractional for some private-credit assets; the SDK validates client-side. |
| settlement.currencyrequired | "USDC" | "USDT" | Both accepted on every supported chain. |
| settlement.chainrequired | "base" | "arbitrum" | "ethereum" | Destination chain for the minted token. |
| settlement.walletrequired | address | Destination EVM address. KYC-gated for non-retail assets. |
| developer_fee | object | Optional platform fee in bps. See Developer fees. |
| idempotency_key | string | Dedup window 24 h. SDK auto-generates a UUIDv4 if omitted. |
Response
json{
"id": "mnt_0a4921pQ9mR3nV",
"object": "mint",
"status": "settled",
"asset": "AAPL",
"quantity": "10.000000",
"notional_usdc": "1842.05",
"flo_fee": { "bps": 10, "amount": "1.84" },
"cost": { "onramp": "0.18", "broker": "0.09", "spread": "0.46" },
"contract": {
"address": "0x7c1f...aE09",
"auto_deployed": false
},
"settlement": {
"currency": "USDC",
"chain": "base",
"wallet": "0x4a8B70fa12cC3d9d3c9b22Ac019bE40a7d11",
"tx_hash": "0xa3b1...9e4c"
},
"settled_at": "2026-04-21T14:33:02Z"
}Auto-deploy on first mint
Flo runs an ERC-20 factory contracton every supported chain. If you mint an asset whose token contract hasn't been deployed yet on that chain (say, the first time anyone mints NVDA on Arbitrum), Flo automatically deploys it inside the same transaction. You do nothing — no pre-deploy step, no upfront gas, no contract-address lookup before your first call.
- The first mint for a given (asset, chain) pair pays a one-time gas premium for the
CREATE2deployment on top of the usual mint gas. Typical premium:~180k gason L2s,~800k gason Ethereum L1. Charged to the caller, not the end user. - Every subsequent mint for that (asset, chain) hits the normal mint gas — the contract is reused.
- The response includes
contract.addressandcontract.auto_deployed: truewhen a deployment happened, so you can cache the address or index it downstream. A separatemint.contract_deployedwebhook fires the first time each contract lands on chain. - Deterministic addresses: contracts deploy via
CREATE2with a Flo-controlled salt, so the address for(AAPL, base)is the same no matter who triggers the first deploy. Safe to hard-code in downstream integrations after the first mint.
client.tokens.ensure_deployed(asset, chain)once per (asset, chain) pair. It's idempotent and only pays the deploy premium if the contract is missing.State machine (sync + async)
During core market hours and on fractional-capable venues, the mint call returns status: "settled" inline. Outside core hours or on non-fractional assets the broker may fill asynchronously — in those cases the call returns quickly with an intermediate status and the rest of the lifecycle arrives via webhooks.
| Field | Type | Description |
|---|---|---|
| queued | intermediate | Accepted by Flo, not yet routed to the broker. Typical when you call outside session hours. Webhook: mint.queued. |
| filled | intermediate | Broker acknowledged the fill; on-chain settlement tx is pending. Webhook: mint.filled. |
| settled | terminal | ERC-20 credited on-chain, SPV ledger updated, reconciled. Webhook: mint.settled. |
| failed | terminal | Broker rejected or settlement reverted. Any held funds are auto-refunded to the source wallet. Webhook: mint.failed with failure_reason. |
Every state carries forward the original mint.id (mnt_…) so you can poll client.mints.retrieve(id) at any time, or subscribe to the webhook lane for push updates. Trade fees are refunded automatically on failed.
Errors
asset_not_found— ticker not in the universe or delisted mid-session.market_closed_non_fractional— this asset requires live-market fills and the venue is shut. Retry during session hours.insufficient_funds— source wallet balance doesn't cover notional + cost.jurisdiction_restricted— the settlement wallet resolves to a restricted jurisdiction. See Security.
Redeem — burn tokens, receive stablecoins
redeem burns any Flo token and routes a stablecoin payout to the wallet you specify. The SPV instructs the prime broker to offset the underlying at the best available price; the on-chain stablecoin payout follows once that offset fills.
redeemcall. When there's sufficient stablecoin liquidity in the payout pool the settlement can happen inside a few blocks; when liquidity is tight, Flo fills as soon as possible — typically in under an hour. Subscribe to redeem.settled (or poll client.redeems.retrieve(id)) for the terminal state instead of blocking on the call's return.# The redeem call returns the moment Flo accepts the burn — the stablecoin
# payout follows asynchronously. Use webhooks or a retrieve-poll loop.
result = client.redeem(
position_id="mnt_0a4921pQ9mR3nV",
quantity=10,
payout={"currency": "USDT", "chain": "arbitrum", "wallet": "0x9f2c...e41a"},
)
# result.status is likely "queued" or "filled" — watch for "settled" via webhook.| Field | Type | Description |
|---|---|---|
| position_idrequired | string | The mint ID or a Flo token balance record. |
| quantityrequired | number | Can be less than the original mint. Partial redemption is free. |
| payout.currencyrequired | "USDC" | "USDT" | Flo does the stable↔stable swap if you redeem in a different currency than you minted. |
| payout.chainrequired | chain | Can differ from the chain the token lives on — Flo bridges internally. |
| payout.walletrequired | address | Destination for proceeds. Same KYC rules apply as at mint. |
Settlement timing
| Field | Type | Description |
|---|---|---|
| queued | intermediate | Burn accepted, broker offset instructed. Typical window under normal conditions: seconds to a few minutes. |
| filled | intermediate | Broker has offset the underlying; Flo is waiting on stablecoin liquidity in the payout pool. |
| settled | terminal | Stablecoin payout landed on-chain at payout.tx_hash. Webhook: redeem.settled. |
| failed | terminal | Broker rejected the offset or the burn was unwound. Tokens are restored to the holder; webhook: redeem.failed. |
In practice, queued → settled lands in well under an hour for every mainstream asset class. If the payout pool is temporarily short on the requested currency-chain pair, Flo holds the fill and releases as soon as liquidity is available — no action needed on your side.
Pricing
Redeem fees follow the schedule on the pricing page— cost + your tier's bps markup. See the tier comparison table for the current per-tier trade rate; the authoritative schedule lives there so you never have to cross-reference the docs against pricing.
Positions — live NAV and yield
Read any tokenized position by token ticker and holder wallet. Returns live NAV (oracle-pegged), accrued yield net of fees, and the underlying broker attestation URL for audit trails. Free on every tier.
pos = client.positions.get("AAPL", wallet="0x4a8B...")
# or list every position your key can read
for p in client.positions.list(limit=100):
print(p.token, p.quantity, p.nav_usdc)Response fields
| Field | Type | Description |
|---|---|---|
| token | string | Ticker. |
| quantity | decimal | Fractional holding. |
| nav_usdc | decimal | Latest NAV × quantity, in USDC. |
| yield.gross_apy_bps | integer | Trailing-30-day gross yield annualized, in basis points. |
| yield.net_apy_bps | integer | Gross minus management and performance fees. Already net of Flo's take. |
| nav.stale | boolean | True when the NAV tick is older than the venue's stale threshold (usually 120 s). |
| attestation_url | string | Current SPV / broker attestation report for this ticker. |
Bridge — move tokens across chains
Bridge runs on LayerZero v2 with a 3-of-4 DVN quorum. Four independent Decentralized Verifier Networks attest every cross-chain message; any three must agree before the destination mint can land. Gas is pass-through; Flo takes no fee on the message itself.
bdg = client.bridge(
token="AAPL",
quantity=50,
from_chain="base",
to_chain="arbitrum",
destination="0x4a8B...3c9D",
)Route matrix
Live: Base ↔ Arbitrum, Base ↔ Ethereum, Arbitrum ↔ Ethereum. All routes are bidirectional. L2 ↔ L2 hops settle in ~30 seconds, L1 legs in ~1 minute.
Failure semantics
- If the DVN quorum doesn't form within 10 minutes, the source burn is unwound automatically — no manual intervention. Webhook:
bridge.reverted. - If the destination mint fails post-DVN (rare), the relayer retries every 60 s for 30 min before opening an incident. Source burn is not unwound in this case; tokens are recoverable via the SPV.
Borrow overview
Post any Flo token as collateral and receive USDC or USDT in the same transaction. APR is a flat 6–9% band across every tier and every asset class; Max LTV varies by asset class. Liquidation is triggered on a 30-second TWAP, preceded by a 12-hour grace window and three webhook-driven health-factor alerts.
Max LTV and liquidation LTV by asset class
| Field | Type | Description |
|---|---|---|
| Large-cap equities | 70% / 78% | AAPL, NVDA, TSLA, MSFT, S&P 500 constituents |
| Blue-chip ETFs | 75% / 82% | SPY, QQQ, VTI, VOO — broad-market index ETFs |
| Government bonds | 85% / 92% | TLT, SGOV, IEF — US Treasury-backed |
| Corporate credit | 65% / 75% | LQD, HYG — investment-grade and high-yield corporates |
| Private credit | 40% / 55% | ARCC, BXSL — BDC and direct-lending tokens |
Open a borrow position
loan = client.borrow.open(
collateral={"token": "AAPL", "quantity": 500},
borrow={
"amount": 42000,
"currency": "USDC",
"chain": "base",
"wallet": "0x4a8B...3c9D",
},
)Response
json{
"id": "brw_3hQ8mL2pV6wY",
"status": "funded",
"collateral": { "token": "AAPL", "quantity": "500.000000", "value_usdc": "93710.00" },
"borrow": { "amount": "42000.000000", "currency": "USDC", "tx_hash": "0xa3b1...9e4c" },
"ltv_bps": 4482,
"liquidation_ltv_bps": 7000,
"apr_bps": 612,
"health_factor": "1.56",
"funded_at": "2026-04-21T14:33:02Z"
}Repay, top-up, close
# Partial repay
client.borrow.repay("brw_3hQ8mL2pV6wY", amount=10_000)
# Top-up collateral to raise health factor
client.borrow.top_up("brw_3hQ8mL2pV6wY", collateral={"token": "AAPL", "quantity": 50})
# Close — pays off full principal + accrued interest, releases all collateral
client.borrow.close("brw_3hQ8mL2pV6wY")amount_bps can be attached for your origination markup.Liquidation mechanics
Health factor
Every open borrow position carries a health_factor— a single dimensionless number that tells you how close to liquidation the loan is. It's computed as:
texthealth_factor = (collateral_value_usdc × liquidation_ltv) / debt_usdc
Where:
collateral_value_usdc = live NAV × collateral quantity
liquidation_ltv = asset-class liquidation threshold (see table below)
debt_usdc = outstanding principal + accrued interesthealth_factor > 1.0— healthy; the collateral covers the debt at the liquidation LTV.health_factor == 1.0— liquidation is imminent. Any further adverse move triggers the 30-second TWAP breach.health_factor < 1.0— underwater on the liquidation LTV. Grace window and partial liquidations kick in.
Worked example. 500 AAPL @ $187.42 posted as collateral, 42,000 USDC borrowed, large-cap liquidation LTV of 78%:
textcollateral_value = 500 × 187.42 = 93,710.00 USDC
debt = 42,000.00 USDC
health_factor = (93,710 × 0.78) / 42,000
= 73,093.80 / 42,000
= 1.740AAPL would need to drop roughly 43% (to ~$107.69) before this position becomes liquidatable. Subscribe to borrow.health_low to be woken up at 1.30 / 1.15 / 1.05, well before that point.
Triggers
Liquidation triggers when the loan's LTV crosses the asset-class liquidation threshold against a 30-second TWAP, not a single tick. This protects borrowers from flash-crash wipeouts.
Grace window + health alerts
- Webhook
borrow.health_lowfires at health factors 1.30, 1.15, and 1.05. - Once the liquidation TWAP is breached, a 12-hour grace window opens. Top up or repay during that window and no liquidation is enforced.
- After grace, liquidators can close up to 50% of the position at a time — partial liquidations protect the borrower from a total wipeout on temporary dislocation.
Liquidation penalty
Flat 5%across every tier. The penalty accrues to the liquidation insurance fund, not Flo's P&L.
Live rate lookup
rates = client.borrow.rates()
for r in rates:
print(r.asset_class, r.apr, r.max_ltv)Rates are indicative, subject to market conditions. Flo publishes an effective-APR snapshot every 60 s; SDKs cache it for 5 s client-side to avoid hot-loop blowout.
Developer fees — REST
Pass a developer_feeon any mint / redeem / borrow call to accrue a platform fee to a destination wallet. Balances are ledgered in Flo's database; settlement runs daily at 00:00 UTC (minimum $10) or on-demand via REST.
# Balance by destination wallet
balances = client.fees.balance()
for b in balances:
print(b.destination, b.accrued_usdc, b.pending_usdc, b.settled_usdc)
# Manual settle — only sweeps destinations whose accrued balance >= minimum.
result = client.fees.settle(minimum_usdc=10)
print(result.settled_count, result.total_usdc)Webhooks — subscribe, replay, verify
Subscribe to any lifecycle event. Deliveries are HMAC-SHA256 signed against a per-endpoint secret and a request timestamp to prevent replay. Retries use exponential backoff over ~72 hours with up to 10 attempts. Replay windows extend to 2 years on the paid add-on.
Create a subscription
sub = client.webhooks.create(
url="https://api.your-app.com/flo-webhook",
events=["mint.*", "redeem.*", "borrow.*", "bridge.*"],
secret="whsec_replace_with_your_signing_secret",
)
print(sub.id, sub.url)Event families
mint.*—queued,filled,settled,failed,contract_deployedredeem.*—queued,filled,settled,failedborrow.*—funded,health_low,repaid,liquidatedbridge.*—queued,sent,delivered,revertedposition.*—nav_updated,yield_accruedfee.*—accrued,settled
Delivery headers
| Field | Type | Description |
|---|---|---|
| X-Flo-Signature | string | HMAC-SHA256 hex digest of timestamp + "." + raw_body, signed with your endpoint secret. |
| X-Flo-Signature-Timestamp | integer | Unix seconds at which Flo signed the request. Reject deliveries whose timestamp is more than 300 seconds off from your server clock — this kills replay attacks. |
| X-Flo-Delivery-Id | string | wh_.... Persist it in your handler to deduplicate at-least-once deliveries. |
| X-Flo-Event | string | Event type, e.g. mint.settled. Mirrors body.type. |
| X-Flo-Webhook-Id | string | Subscription ID. Useful when a single handler serves multiple subscriptions. |
Signature verification (replay-safe)
import hmac, hashlib, time
SKEW_SECONDS = 300 # 5 minutes
def verify(body: bytes, secret: str, signature: str, timestamp: str) -> bool:
# 1. Reject stale events to kill replay attacks.
ts = int(timestamp)
if abs(time.time() - ts) > SKEW_SECONDS:
return False
# 2. HMAC is over "${timestamp}.${raw_body}", never just the body.
signed_payload = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Framework-agnostic usage
# verify(request.body, "whsec_...", request.headers["X-Flo-Signature"], request.headers["X-Flo-Signature-Timestamp"])app.use("/flo-webhook", express.raw({ type: "application/json" })). Next.js route handlers: use await req.text() before JSON.parse.Retry schedule
When your endpoint returns a non-2xx or times out (10 s), Flo retries on this schedule with full jitter. After the 10th failed attempt (~72 hours later) the delivery is marked abandoned and appears in the dashboard as a failed delivery until you replay it manually.
| Field | Type | Description |
|---|---|---|
| Attempt 1 | t + 0s | Immediate — counts as the first delivery. |
| Attempt 2 | t + 30s | |
| Attempt 3 | t + 5m | |
| Attempt 4 | t + 30m | |
| Attempt 5 | t + 2h | |
| Attempt 6 | t + 6h | |
| Attempt 7 | t + 12h | |
| Attempt 8 | t + 24h | |
| Attempt 9 | t + 48h | |
| Attempt 10 | t + 72h | Final. After this the delivery is abandoned. |
Flo treats any 2xx status as a successful delivery — including 200, 201, 202, 204. A slow 200(> 10 s) still counts as timeout and triggers a retry. Respond fast, do heavy work async.
Idempotent handler pattern
At-least-once means the same event can arrive twice. Persist X-Flo-Delivery-Id on first processing and drop duplicates.
seen: set[str] = set() # use Redis/DB in prod
async def handle(req):
delivery_id = req.headers["X-Flo-Delivery-Id"]
if delivery_id in seen:
return Response(status=200) # idempotent drop
if not verify(req.body, SECRET, req.headers["X-Flo-Signature"], req.headers["X-Flo-Signature-Timestamp"]):
return Response(status=401)
seen.add(delivery_id)
await enqueue_for_processing(json.loads(req.body))
return Response(status=200)Replay from the dashboard
Every delivery attempt is captured for 30 days (2 years on the add-on). From Webhooks → deliveries you can:
- View the raw request body, headers, response status, and latency of every attempt.
- Replay a single delivery to the original endpoint or redirect it to a different URL (useful when rolling forward a fix).
- Bulk-replay every
abandoneddelivery within a time range after a rollout fixes a handler bug.
Testing webhooks locally
You have three options. In order of what we'd reach for:
- Flo CLI tunnel —
flo webhooks listen --forward-to localhost:3000/flo-webhook. Installs a short-lived endpoint on Flo's side that streams deliveries to your machine over HTTPS. Prints the effective signing secret so your verifier works unchanged. Sandbox-only. - Dashboard trigger — from Webhooks pick any event type and click Send test delivery. Fires a synthetic event through the full signing + retry stack so you can exercise your handler without a real mint.
- ngrok —
ngrok http 3000, paste the public URL into the subscription URL, done. Works in sandbox and live; remember to use a separate subscription so live keys never touch your laptop.
Token catalog
The /v1/tokens surface is a read-only catalog of every instrument Flo has tokenized: supply, NAV, per-chain distribution, SPV identifiers, audit URLs. Updated continuously — do not cache beyond 60 s.
# List the full universe (auto-paginates).
for token in client.tokens.list(limit=100):
print(token.ticker, token.supply, token.nav_usdc)
# Retrieve one — full SPV record + latest attestation URL.
aapl = client.tokens.retrieve("AAPL")
print(aapl.spv_id, aapl.attestation_url)Customers & attestations
Flo does not run KYC or KYB. You use any vendor you want — Jumio, Onfido, Persona, Sumsub, Middesk, Alloy, Veriff, or your own in-house stack — and post the resulting attestationto Flo. Flo normalizes it, tags it to the customer's wallet + external ID, tamper-hashes it, and indexes it against that customer's on-platform activity. Raw PII storage is opt-in (encrypted behind a customer-managed key) — the default is hash-only. Included free in every tier.
Submit an attestation
customer = client.customers.upsert(
external_id="acme-user-1294",
wallet="0x4a8B70fa12cC3d9d3c9b22Ac019bE40a7d11",
attestation={
"kind": "kyc",
"provider": "jumio",
"vendor_reference": "JMO-2026-04-19-aX9k",
"attested_at": "2026-04-19T10:22:00Z",
"expires_at": "2028-04-19T10:22:00Z",
"signals": ["document_verified", "liveness_verified", "sanctions_clear"],
"attestation_hash": "0x91fe...a04c",
"pii": "hash_only",
},
)
print(customer.id, customer.attestation.id)Attestation schema
| Field | Type | Description |
|---|---|---|
| kindrequired | "kyc" | "kyb" | Individual (kyc) or business (kyb). Drives reporting + dashboard filtering. |
| providerrequired | enum | "custom" | One of jumio, onfido, persona, sumsub, middesk, alloy, veriff, or custom. Unrecognized values must use custom + a provider name in provider_name. |
| vendor_referencerequired | string | Stable ID returned by your vendor (Jumio txn ID, Onfido check ID, Middesk business ID). Used for audit back-trace. |
| attested_atrequired | ISO-8601 | When the vendor signed off. Used to compute staleness. |
| expires_at | ISO-8601 | Optional. Flo fires customer.attestation_expiring 30 days out and customer.attestation_expired on the day. |
| signalsrequired | string[] | Any of document_verified, liveness_verified, sanctions_clear, pep_check_clear, accreditation_verified, ubo_verified, source_of_funds_verified, addresses_verified. Query-filterable. |
| attestation_hashrequired | hex (0x…) | SHA-256 of the raw attestation payload. Lets auditors prove the record wasn't tampered with. |
| pii | "hash_only" | "encrypted" | Default hash_only — Flo stores metadata + hash, never the raw payload. Switch to encrypted and include encrypted_payload to store a ciphertext blob behind your KMS key. |
| encrypted_payload | base64 ciphertext | Optional. Present only when pii: "encrypted". Encrypted at-rest with the key referenced by kms_key_id. |
| kms_key_id | string | Optional AWS KMS / GCP KMS / HashiCorp Vault key alias for the encrypted blob. Flo never holds the key — decryption is always customer-driven. |
Response
json{
"id": "cus_k01m84",
"object": "customer",
"external_id": "acme-user-1294",
"wallet": "0x4a8B70fa12cC3d9d3c9b22Ac019bE40a7d11",
"attestation": {
"id": "att_0f9a21",
"kind": "kyc",
"provider": "jumio",
"vendor_reference": "JMO-2026-04-19-aX9k",
"attested_at": "2026-04-19T10:22:00Z",
"expires_at": "2028-04-19T10:22:00Z",
"signals": ["document_verified", "liveness_verified", "sanctions_clear"],
"attestation_hash": "0x91fe...a04c",
"pii": "hash_only",
"stored_at": "2026-04-19T10:22:04Z"
}
}List & query by signal
for c in client.customers.list(signal="accreditation_verified", min_notional=250_000):
print(c.id, c.wallet, c.attestation.expires_at)Query surface
The attestation index is designed to answer the questions auditors actually ask. A few examples you can GET directly:
GET /v1/customers?signal=accreditation_verified&min_notional=250000— every customer with active accreditation whose mint history tops $250k.GET /v1/customers?attestation.expires_before=2026-06-01— every attestation that'll expire before your next audit cycle, so you can chase re-verifications early.GET /v1/customers?provider=sumsub&attested_after=2026-01-01— provenance query for a specific vendor (useful when a vendor has a data-breach incident and you need exposure count).
Reconciliation reports
Daily cash / position break reports between the prime-broker statements and onchain supply. The reconciliation job runs at 00:00 UTC; diffs above $100 automatically page on-call and pause mints for the affected ticker until resolved.
report = client.reconciliation.daily(date="2026-04-21")
print(report.status, report.summary.diff_usdc)
for line in report.lines:
if line.status == "break":
print("BREAK:", line.token, line.chain, line.diff)Response schema
json{
"object": "reconciliation_report",
"date": "2026-04-21",
"status": "matched",
"generated_at": "2026-04-22T00:04:12Z",
"summary": {
"tokens_checked": 8124,
"breaks": 0,
"total_onchain_usdc": "412,038,217.55",
"total_broker_usdc": "412,038,217.55",
"diff_usdc": "0.00"
},
"lines": [
{
"token": "AAPL",
"chain": "base",
"onchain_supply": "18421.084523",
"broker_position": "18421.084523",
"diff": "0.000000",
"nav_usdc": "187.42",
"onchain_value_usdc": "3,452,307.61",
"broker_value_usdc": "3,452,307.61",
"status": "matched",
"attestation_url": "https://attest.flo.finance/ATT-2026-04-21-AAPL.pdf"
}
],
"pagination": { "has_more": false, "next_cursor": null }
}Field reference
| Field | Type | Description |
|---|---|---|
| date | ISO date | UTC calendar day the report covers. |
| status | "matched" | "break" | "pending" | matched = every line matched; break = one or more diffs > $100; pending = job still running. |
| summary.breaks | integer | Count of lines whose diff exceeded the $100 threshold. |
| summary.diff_usdc | decimal | Aggregate absolute-value USD difference across all lines. Should round to 0 on a clean day. |
| lines[].token | string | Ticker. |
| lines[].chain | chain | Which chain this line is for. A ticker with distribution across 3 chains produces 3 lines. |
| lines[].onchain_supply | decimal | Sum of ERC-20 balances across all holder wallets at 00:00 UTC. |
| lines[].broker_position | decimal | Broker-of-record share count for the SPV vault at 00:00 UTC (T+0 cash-basis). |
| lines[].diff | decimal | onchain_supply − broker_position. Positive = tokens over-issued. Negative = under-issued. Should be 0.000000. |
| lines[].status | "matched" | "break" | Per-line status. break auto-pauses mint for (token, chain) until resolved. |
| lines[].attestation_url | string | PDF attestation for that line from Accountable, our independent third-party attestor. |
status === "break" your affected tickers will return mint_paused_reconciliation on any new mint until operations clear the break. Redeems are not paused — holders can always exit.Idempotency
Every mutating SDK call and REST endpoint accepts an Idempotency-Key. Flo dedupes against the key for 24 hours; retries after the first successful response return the cached result verbatim, including the original tx hash.
- Keys must be <= 128 bytes, case-sensitive.
- If the cached response is a 5xx, the next retry re-runs the request. We do not pin transient failures.
- Cross-environment collisions are impossible — sandbox and live have independent idempotency stores.
- SDKs generate a UUIDv4 automatically when you don't pass one; override only when you want app-side control.
Rate limits
Per-key requests-per-second, by tier:
- Startup — 500 RPS
- Growth — 1,000 RPS
- Scale — 2,500 RPS
- Enterprise — 5,000 RPS
- Sandbox — effectively uncapped (soft 10,000 RPS)
Responses carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. On a hard 429 we include Retry-After in seconds. SDKs back off automatically with full jitter up to max_retries.
Burst allowance
Every tier allows a 2× burst for the first 10 seconds of a cold window. After that, sustained traffic is shaped to the tier cap. Read endpoints (GET) do not consume the mutating-budget quota on Scale/Enterprise.
Pagination
All list endpoints are cursor-paginated. Pass ?limit=100&starting_after=obj_…; responses return has_more and next_cursor. Max limit is 100.
# SDK auto-paginates
for position in client.positions.list():
...Errors — Problem-JSON
Every non-2xx returns a Problem-JSON envelope (RFC 7807 + Flo extensions). The envelope carries the standard fields plus error-specific context you can switch on without string-matching.
| Field | Type | Description |
|---|---|---|
| type | URI | Canonical URL for the error code. Dereference at docs.flo.finance/errors/<code>. |
| title | string | Short human-readable summary. |
| status | integer | HTTP status (echoed for offline log analysis). |
| detail | string | Longer human-readable explanation with concrete values where applicable. |
| instance | path | The request path that triggered it. |
| request_id | string | req_…. Include this in support tickets — it indexes every log line end-to-end. |
| code | string | Machine-readable code — switch on this, not on the HTTP status. |
| retryable | boolean | Whether the same request with the same idempotency key may succeed on retry. |
Retry / idempotency matrix
Rule of thumb. 4xx with retryable: false is permanent — fix the request first. 5xx and 429 are transient and safe to retry with the same idempotency key. 409 idempotency_mismatch is the special case: use a fresh key, not a retry.
Full error catalog
| Field | Type | Description |
|---|---|---|
| unauthorized | 401 · permanent | Missing or malformed Bearer token. |
| forbidden | 403 · permanent | Key is valid but lacks scope for this resource. |
| key_lacks_live_scope | 403 · permanent | Using a sk_test_ key against api.flo.finance or vice-versa. Switch env. |
| ip_not_allowed | 403 · permanent | Request origin outside the IP allowlist attached to this key. |
| kyc_required | 403 · permanent | Live operation attempted before KYC approval. Response includes kyc_status and kyc_flow_url. |
| jurisdiction_restricted | 403 · permanent | Settlement wallet or requester country is on the restricted list. Response includes country and category. |
| asset_not_found | 404 · permanent | Ticker not in the universe or delisted mid-session. |
| position_not_found | 404 · permanent | position_id doesn't resolve on this account. |
| market_closed_non_fractional | 409 · retry-after-window | Non-fractional asset, venue is shut. Response includes next_session_at (ISO). |
| idempotency_mismatch | 409 · permanent | Same Idempotency-Key with different body. Use a new key. |
| mint_paused_reconciliation | 409 · transient | Reconciliation break; retry after the break is resolved (status page updated). |
| rate_limited | 429 · retryable | Over your tier RPS. retry_after_seconds in body, Retry-After header mirrors it. |
| ltv_exceeded | 422 · permanent | Requested borrow above the asset-class max LTV. Body: max_ltv_bps, requested_ltv_bps. |
| health_too_low | 422 · permanent | Position would open underwater. Post more collateral or borrow less. |
| insufficient_funds | 422 · permanent | Source wallet balance below required. Body: required_usdc, available_usdc, shortfall_usdc. |
| guardrail_triggered | 422 · retry-after-window | Per-block circuit breaker. Body: limit, limit_value, current_value, retry_after_seconds. |
| bridge_route_unsupported | 422 · permanent | Flo doesn't support this (from_chain, to_chain) pair yet. |
| bridge_dvn_timeout | 504 · auto-recovered | DVN quorum didn't form in 10 min. Source burn is unwound automatically; listen for bridge.reverted. Do NOT retry the same bridge — the tokens are back on the source chain already. |
| internal_error | 500 · retryable | Flo-side. Retry with the same idempotency key. |
| service_unavailable | 503 · retryable | Scheduled maintenance or degraded upstream. Retry with backoff. |
Example bodies
rate_limited (429) — standard SDK catches this and auto-retries up to max_retries.
json{
"type": "https://docs.flo.finance/errors/rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Exceeded 1000 RPS on key sk_live_••a19c.",
"instance": "/v1/mint",
"request_id": "req_0b1204",
"code": "rate_limited",
"retryable": true,
"retry_after_seconds": 2,
"limit_rps": 1000,
"observed_rps": 1184
}insufficient_funds (422) — permanent. Surface shortfall_usdc to your user.
json{
"type": "https://docs.flo.finance/errors/insufficient_funds",
"title": "Insufficient USDC to settle this trade",
"status": 422,
"detail": "Need 1,842.05 USDC, available 1,200.00 USDC.",
"instance": "/v1/mint",
"request_id": "req_0b1205",
"code": "insufficient_funds",
"retryable": false,
"required_usdc": "1842.05",
"available_usdc": "1200.00",
"shortfall_usdc": "642.05"
}guardrail_triggered (422) — per-block circuit breaker. Body names the limit you hit so you can cool down or back off.
json{
"type": "https://docs.flo.finance/errors/guardrail_triggered",
"title": "Mint rejected by per-block guardrail",
"status": 422,
"detail": "Your address exceeded mint_max_notional_per_address_per_block at block 8102443.",
"instance": "/v1/mint",
"request_id": "req_0b1206",
"code": "guardrail_triggered",
"retryable": true,
"limit": "mint_max_notional_per_address_per_block",
"limit_value": "250000.00",
"current_value": "268420.50",
"retry_after_seconds": 12
}bridge_dvn_timeout (504) — source burn auto-unwound. The tokens are already back on the source chain by the time you see this response; do not re-submit the same bridge request.
json{
"type": "https://docs.flo.finance/errors/bridge_dvn_timeout",
"title": "Bridge reverted — DVN quorum did not form within 10 min",
"status": 504,
"detail": "Source burn has been auto-unwound. Tokens are restored on base at tx 0x9f...c1.",
"instance": "/v1/bridge",
"request_id": "req_0b1207",
"code": "bridge_dvn_timeout",
"retryable": false,
"bridge_id": "brg_9xK4pT1mR7nZ",
"unwind_tx": "0x9fae...c14b",
"webhook_fired": "bridge.reverted"
}idempotency_mismatch (409) — same key, different body. Use a fresh idempotency key.
json{
"type": "https://docs.flo.finance/errors/idempotency_mismatch",
"title": "Idempotency-Key already used with a different request body",
"status": 409,
"detail": "Key 'mint_2026_04_21_0001' was first used with a different asset or quantity.",
"instance": "/v1/mint",
"request_id": "req_0b1208",
"code": "idempotency_mismatch",
"retryable": false,
"original_request_id": "req_0af9d5",
"action": "Retry with a different Idempotency-Key."
}market_closed_non_fractional (409) — wait for the session. next_session_at is an ISO timestamp in venue timezone-agnostic UTC.
json{
"type": "https://docs.flo.finance/errors/market_closed_non_fractional",
"title": "Underlying venue is closed and this asset requires live fills",
"status": 409,
"detail": "NYSE is closed; the earliest next fill window opens at 13:30 UTC.",
"instance": "/v1/mint",
"request_id": "req_0b1209",
"code": "market_closed_non_fractional",
"retryable": true,
"asset": "NVDA",
"venue": "NYSE",
"next_session_at": "2026-04-22T13:30:00Z",
"seconds_until_session": 41280
}kyc_required (403) — hand the user kyc_flow_url for a hosted flow, or call /v1/customers to create a link programmatically.
json{
"type": "https://docs.flo.finance/errors/kyc_required",
"title": "Live operation requires an approved KYC record",
"status": 403,
"detail": "Your organization has not completed KYC. Sandbox remains available.",
"instance": "/v1/mint",
"request_id": "req_0b120a",
"code": "kyc_required",
"retryable": false,
"kyc_status": "not_started",
"kyc_flow_url": "https://dashboard.flo.finance/kyc"
}code, not on HTTP status. Two 422s can need very different UX — ltv_exceeded needs a collateral suggestion, insufficient_funds needs a top-up CTA, guardrail_triggered is a backoff.Versioning
The URL carries the current major version (/v1/…). Breaking changes cut a new major; we commit to supporting the previous major for 18 months after a new one ships. Pin your SDK version in production — we publish non-breaking additions weekly.
Opt into dated non-breaking variants via the Flo-Version header (ISO date). When a header is absent, your account is pinned to the version on the first successful live call; you can upgrade the pin from the dashboard.
Security posture
Flo enforces defense-in-depth across four boundaries: API edge, SDK guardrails, on-chain contracts, and prime-broker custody. Each boundary is independently auditable and independently pauseable.
- API edge: IP geofencing, sanctions screening, KYC attestation, rate limiting, and the per-block guardrails below.
- Onchain: LayerZero v2 with a 3-of-4 DVN quorum, 72-hour timelock on parameter changes, single-member pause (any security council member), 4-of-7 multisig unpause.
- SPV: Cayman SPV with a trust-like shareholding arrangement and an independent director on the board; Ankura Trust holds first-priority security interest.
- Custody: segregated omnibus at IBKR + Alpaca; daily onchain supply ≤ custodied underlying, attested monthly.
Per-block guardrails
Every mint, redeem, borrow, and bridge call passes through a set of conservative circuit breakers. Exact thresholds live on the Trust page and are tuned per-asset; the categories below are the shape of what's enforced. A request that would breach any breaker is rejected with guardrail_triggered.
Mint
mint_max_notional_per_address_per_block— per-wallet notional cap within a single block. Prevents fat-finger and MEV-style stuffing.mint_max_total_per_block— platform-wide cap per block. Protects prime-broker fill capacity.mint_min_per_tx— floor on gas-wasting dust (e.g.$10).mint_daily_notional_per_asset— rolling-window cap per ticker; gates concentration.
Redeem
redeem_max_per_block— mirrors the mint cap; prevents run-on-the-bank behavior under stress.redeem_cool_down— optional per-wallet cooldown when the daily redemption ratio exceeds a tunable threshold; configurable by the API customer at Scale and above.
Borrow
borrow_max_notional_per_address— concentration cap per wallet.borrow_max_total_outstanding— systemic cap on the borrow book. Protects the liquidation engine's ability to unwind in adverse markets.- Max LTV per asset class is published in Borrow overview.
Bridge
bridge_min_amount_per_tx— dust floor.bridge_max_amount_per_tx— per-message risk cap inside the 3-of-4 DVN quorum.bridge_max_total_per_block— per-route rate limit.
LayerZero 3-of-4 DVN
Every bridge message is attested by four independent Decentralized Verifier Networks. Any three must agree before the destination mint can land. No single verifier can sign a fraudulent bridge. If the quorum doesn't form within 10 minutes, the source burn is auto-unwound and bridge.reverted fires.
DVN providers
- LayerZero Labs (default)
- Google Cloud
- Polyhedra
- Nethermind
SPV structure & legal wrapper
- Operating entity: Flo Technologies Ltd., incorporated in the British Virgin Islands.
- Issuance vehicle: bankruptcy-remote SPV incorporated in the Cayman Islands, with a trust-like shareholding arrangement and an independent director on the board. Single-purpose note issuance charter; perfected security interests are current and enforceable; rotated monthly for attestation.
- Security agent: Ankura Trust — independent, holds first-priority perfected security interest, authorized to initiate liquidation on LTV breach.
- Holder claim: direct legal claim on the underlying asset inside the SPV — not a claim on any Flo operating entity. If Flo shuts down, tokens remain redeemable through the SPV estate and Ankura.
Custody & attestations
Underlying securities are held in segregated omnibus at Interactive Brokers and Alpaca Securities. SIPC coverage applies on the IBKR side ($500K). Daily reconciliation guarantees that onchain supply never exceeds custodied underlying; violations pause mints automatically and page on-call.
Audit cadence
- Smart contract audits: Sherlock, Hellborn, and Cantina — three independent firms. Every contract audited, zero critical findings to date.
- Formal verification: mint / redeem / bridge logic mathematically proven correct.
- Reserve attestation: monthly, issued by Accountable — our sole independent attestor.
- Proof of Reserves: Merkle-tree-based, onchain contract. Root recomputable from a JSON leaf.
- SOC 2 Type II: annual; currently in progress.
System status
Live uptime, per-region latency, and incident feed at status.flo.finance. Incidents post within 60 seconds of detection. Historical uptime for the past 90 days is published per component (mint, redeem, borrow, bridge, webhooks, dashboard).
API & SDK changelog
Non-breaking additions ship weekly; breaking changes cut a new major with 18 months of parallel support. Every rate and product change is logged at the pricing changelog; the SDK release notes and API changelog are mirrored on GitHub:
Postman collection
Every REST endpoint is pre-baked in our Postman collection with sandbox base URLs and example bodies. Swap the key env var and hit Send — no SDK install required for exploratory work.
- Download collection — or paste into Postman Web via Import → Link:
https://flo.finance/postman/flo-api.postman_collection.json. - Download environment — switches between sandbox and production with one env flip.
Support & contact
- Developer support: developers@flo.finance
- Enterprise / SLA: enterprise@flo.finance
- Security disclosures: security@flo.finance · Flo-operated bug bounty — up to $500K for critical findings.
- Compliance / AML / data processing: compliance@flo.finance
Missing something? Open an issue on GitHub or email developers@flo.finance.