Developer documentation
Everything a system needs to put a deal into the desk, follow it, verify its receipt, and take it to settlement. Base URL https://desk.3fs.app. All responses are JSON unless stated. Times are ISO-8601 UTC.
Overview
A deal moves through a fixed status machine. Nothing skips a stop and nothing moves right without earning it.
INTAKE → DOCUMENTS → SCREENING → ATTESTATION_REQUESTED → VERIFIED → FORWARDED → TOKENIZED → SETTLED
↘ DECLINED (from any open status)
Three actors call the API. Brokers and clients hold a key (dk_…) and can open deals, record documents, drop files and read their own deals. Operators (the desk) hold a session and run the checklist, screening, bank confirmation, forwarding and declines. Rails hold a shared secret and report settlement states back.
What is real, what is not
| Subsystem | State | Note |
|---|---|---|
| Desk API, tracker, receipts, app, push | REAL | Live, verified end to end. |
| Screening agents | REAL external | Live on twin.unykorn.org; the desk's automatic adapter is off until its agent wallet is funded, so results are recorded manually. |
| EVM contracts | LOCAL-DEMO | 56 passing Foundry tests; sandbox deployment; not on mainnet. |
| XRPL rails | TESTNET | 22-transaction testnet run; mainnet accounts staged and unfunded. |
| On-chain anchoring | ABSENT | Hashes fixed and queued for approval; no contract deployed, no signer funded. |
| AI guide and intake assistant | NEEDS CREDITS | Wired; model account must be credited. |
| Bots | PAPER | No execution path exists. |
Authentication
| Caller | Credential | Scope |
|---|---|---|
| Broker or client | Authorization: Bearer dk_… | Create deals, record documents, drop files, read own deals. 120 requests/minute/key. |
| Operator | Signed session cookie (/login, supports ?next=) | Everything above plus checklist, screening, bank confirmation, forward, decline, brokers, inbox, export, voice, bots. |
| Principal | Session cookie or bypass link | Operator scope plus money approvals and admin purge. |
| Rails | Authorization: Bearer <RAILS_SECRET> | Report TOKENIZED and SETTLED on a forwarded deal. |
Keys are shown once and stored as SHA-256 hashes. Rotate with POST /api/v1/brokers/{id}/rotate, revoke with POST /api/v1/brokers/{id}/revoke. A revoked key fails with 401 immediately. An invite link (/submit?key=dk_…) carries the key once; the page stores it in the browser and strips it from the address bar.
POST /api/v1/deals
Open a deal file. Required: entity, contact_name, contact_email, stated_amount, currency, holding_bank. Send Idempotency-Key so retries are safe.
curl -X POST https://desk.3fs.app/api/v1/deals \
-H "Authorization: Bearer dk_…" -H "Content-Type: application/json" -H "Idempotency-Key: 7b1c-…" \
-d '{"entity":"ABC Holdings LLC","contact_name":"J. Doe","contact_email":"j@abc.example",
"stated_amount":"25000000","currency":"USD","holding_bank":"Example Bank N.A."}'
201 { "deal": { "id": "DL-2026-0012", "status": "INTAKE", "track_code": "NY-7K3PQ9AB", "gate": [ …10 items… ], … },
"next": { "step": "Get the first document.", "owner": "broker", "urgency": 3, "action": "…", "why": "…" } }
GET /api/v1/deals/{id} returns the deal, its next step and its receipt. GET /api/v1/deals/{id}/next returns the next step alone. Give the client the track_code; it is also delivered in every webhook.
POST /api/v1/deals/{id}/documents · POST /api/v1/inbox
Record a document on a deal: multipart/form-data with file (hashed server-side, bytes discarded) or JSON {name, sha256, size, mime}. The first document moves INTAKE to DOCUMENTS.
The inbox takes anything that is not a structured deal yet. JSON up to 256 KiB is kept for review; everything else is hashed and discarded. Each submission returns a tracking reference and code.
curl -X POST https://desk.3fs.app/api/v1/inbox -H "Authorization: Bearer dk_…" \
-F "note=ABC Holdings, EUR position" -F "file0=@confirmation.json"
200 { "submissions": [ { "id": 12, "name": "confirmation.json", "sha256": "…", "cid": "bafkrei…",
"track_ref": "S-12", "track_code": "NY-F4FE8A53", "track_url": "https://desk.3fs.app/track?ref=S-12&code=NY-F4FE8A53" } ] }GET /api/v1/track?ref=…&code=…
Public, no key. Plain-words status for a client: step 1 to 3, headline, detail, what the client does next, documents with content IDs, Merkle root. ref is S-12 or DL-2026-0012. 30 lookups per minute per IP; a wrong code is a 404.
{ "ref": "DL-2026-0004", "step": 2, "stage": 2, "headline": "We're checking it.",
"next_for_you": "Nothing. This part is on us.", "merkle_root": "b228e3…", "documents": [ { "name": "…", "cid": "bafkrei…" } ] }Trust receipt · GET /api/v1/deals/{id}/receipt
Per document: SHA-256, IPFS CIDv1 (raw codec 0x55, sha2-256 multihash, base32 multibase). Per deal: a Merkle root with a proof per document. Verify a proof in any language:
leaf = SHA256(0x00 ‖ sha256(file))
node = SHA256(0x01 ‖ left ‖ right) // odd last node is paired with itself; leaves sorted by hex
proof = [ { "pos": "right", "hash": "…" }, … ] // walk up: pos says which side the sibling is on
valid = fold(proof, leaf) == merkle_root
// JavaScript (browser or Node ≥ 20)
async function verify(sha256Hex, proof, root) {
const H = async (b) => new Uint8Array(await crypto.subtle.digest("SHA-256", b));
const hex = (h) => Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));
const cat = (...p) => { const o = new Uint8Array(p.reduce((n, a) => n + a.length, 0)); let i = 0; for (const a of p) { o.set(a, i); i += a.length; } return o; };
let h = await H(cat(new Uint8Array([0]), hex(sha256Hex)));
for (const s of proof) h = await H(s.pos === "right" ? cat(new Uint8Array([1]), h, hex(s.hash)) : cat(new Uint8Array([1]), hex(s.hash), h));
return [...h].map((b) => b.toString(16).padStart(2, "0")).join("") === root;
}
CIDs are computed, not pinned; the platform never publishes the file. For files over 256 KiB IPFS chunks the file and the root CID differs from the raw-leaf CID shown.
Webhooks
Give a broker a webhook_url and the desk POSTs a signed event on every change. Events: deal.created, deal.documents, deal.attestation_requested, deal.verified, deal.forwarded, deal.declined, deal.tokenized, deal.settled, deal.stale. Body includes deal_id, status, broker_id, track_code, at.
x-desk-timestamp: 1791439000
x-desk-signature: sha256=HMAC_SHA256(secret, `${timestamp}.${raw_body}`)
// verify: recompute over the raw body, compare in constant time, reject if |now - timestamp| > 300 sIdempotency, limits, errors
Idempotency-Key on POST /deals: the same key from the same caller returns the original deal. Rate limit headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset. Every response carries x-request-id. Errors are { "error": "…", "code": "bad_request | unauthorized | forbidden | not_found | unprocessable | rate_limited | not_configured | upstream_error", "request_id": "…" }.
Brokers, clients and payout chains operator
POST /api/v1/brokers { "name", "org", "email", "flat_fee_usd", "webhook_url", "kind": "broker" | "client" }
→ 201 { "broker": {…}, "api_key": "dk_… (once)", "invite_url": "https://desk.3fs.app/submit?key=dk_…" }
POST /api/v1/brokers/{id}/rotate | /revoke
POST /api/v1/brokers/{id}/chain { "chain": [ { "name", "address", "flat_usd" }, … ] } // ≤ 8 links, flat amounts only
→ mirrors FeeEscrow.fundWithChain: broker fee + every link paid in one transaction at M1; refunded on decline
POST /api/v1/brokers/{id}/anchor → 202, fixes sha256(record) and queues approval anchor-{id}; chain write happens when approved and a signer existsNext steps, guide and voice operator
GET /api/v1/guide ranked next moves across the desk, funnel, verified rate, coaching
GET /api/v1/deals/{id}/next deterministic next step for one deal (also for brokers, own deals)
POST /api/v1/agent/guide { "message", "history" } → Claude coaching turn over live desk state (needs ANTHROPIC_API_KEY + credits)
POST /api/v1/agent/intake intake interview; returns a deal draft via a strict tool
GET /api/v1/voice/tour Rocco's 9-step tour with per-step checks
POST /api/v1/voice/ask { "message" } → desk-rules answer (no model) or guide reply; always 200
POST /api/v1/voice/tts { "text" } → audio/mpeg (ElevenLabs) or 503 when not configured (browser voice fallback)Phone app and push
The app at /app is an installable web app (manifest, service worker). Alerts are Web Push (RFC 8291 aes128gcm, RFC 8292 VAPID), accepted by Apple, Google and Mozilla push services.
GET /api/v1/push/key { "publicKey": "…", "configured": true }
POST /api/v1/push/subscribe PushSubscription JSON from pushManager.subscribe() (operator session)
POST /api/v1/push/unsubscribe { "endpoint" }
POST /api/v1/push/test sends "alerts are on" to every device
Events that push: inbox.received, deal.created, deal.documents, deal.attestation_requested, deal.verified, deal.forwarded, deal.tokenized, deal.settled, deal.declined, deal.stale, broker.created, approval.requested. On iPhone, push works only after the app is added to the Home Screen.
Market and bots operator · paper
GET /api/v1/market live XRP/RLUSD book (best bid/ask, sizes, microprice, spread), AMM reserves, formula list
GET /api/v1/bots Avellaneda-Stoikov maker (normalized price), grid maker, AMM-vs-book arbitrage; equity, P&L, fills, Sharpe, drawdown, VaR
POST /api/v1/bots/tick run one paper tick (the cron runs one hourly); POST /bots/{id}/toggle | reset | params | go-live (queues approval)
Public XRPL nodes rate-limit bursts; one tick cycle takes a single market snapshot and the RPC helper falls back across xrplcluster.com, s1 and s2.ripple.com.
Settlement API (UnyKorn rails)
desk → rails POST https://bank.3fs.app/rails/inbound v2 HMAC (x-desk-timestamp, x-desk-signature), 300 s window
rails → desk POST /api/v1/deals/{id}/status { "status": "TOKENIZED" | "SETTLED", "ref": "…" } Authorization: Bearer RAILS_SECRET
desk → hub redacted events to the UnyKorn MCP hub feed (identifiers, statuses, hashes only)Contracts (Solidity 0.8.28, Foundry, OpenZeppelin 5.7) LOCAL-DEMO
| Contract | Role | Key functions |
|---|---|---|
| DealRegistry | Status machine on chain; attestation required for VERIFIED; screening clear for FORWARDED; RAILS_ROLE reports. | registerDeal, setStatus, recordAttestation, recordScreening, forward, railsReport |
| FeeEscrow | Milestone fees M0–M3, broker flat fee, introducer chain (≤ 8), Chainlink Automation release, refund keeps M0. | fund, fundWithChain, release, refund, checkUpkeep, performUpkeep, chainOf, chainTotal |
| AttributionAnchor | Anchors receipt roots and onboarding hashes. | anchor(bytes32) |
| PermissionedToken + IdentityRegistry | ERC-3643-style units; transfers gated by identity claims. | mint, transfer (gated), freeze, clawback |
| AttestationRegistry | EIP-712 bank attestations. | attest, isAttested |
| SubscriptionDvP | Units against funded stablecoin in one transaction. | subscribe, settle |
Tests: 30 (desk) + 26 (rails) passing. Sources in sean-desk/contracts and delevalle-sandbox/contracts.
XRPL rails
Issuer with RequireAuth, Clawback and DefaultRipple off; authorized trust lines per holder; fill-or-kill OfferCreate for delivery-versus-payment (both legs in one ledger close or tecKILLED); PREIMAGE-SHA-256 escrow per tranche; freeze and clawback for recovery. Settlement currency RLUSD (issuer rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De). Testnet run: 22 transactions. Mainnet: accounts staged, unfunded, pending principal approval.
NY RWA is a brand of UnyKorn LLC (Wyoming), a technology and administration service provider; not a bank, broker-dealer, exchange, custodian, trustee, transfer agent, investment adviser, money transmitter or issuer. Documentation version 1.0, 8 October 2026.