NY RWA · Platform

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

SubsystemStateNote
Desk API, tracker, receipts, app, pushREALLive, verified end to end.
Screening agentsREAL externalLive on twin.unykorn.org; the desk's automatic adapter is off until its agent wallet is funded, so results are recorded manually.
EVM contractsLOCAL-DEMO56 passing Foundry tests; sandbox deployment; not on mainnet.
XRPL railsTESTNET22-transaction testnet run; mainnet accounts staged and unfunded.
On-chain anchoringABSENTHashes fixed and queued for approval; no contract deployed, no signer funded.
AI guide and intake assistantNEEDS CREDITSWired; model account must be credited.
BotsPAPERNo execution path exists.

Authentication

CallerCredentialScope
Broker or clientAuthorization: Bearer dk_…Create deals, record documents, drop files, read own deals. 120 requests/minute/key.
OperatorSigned session cookie (/login, supports ?next=)Everything above plus checklist, screening, bank confirmation, forward, decline, brokers, inbox, export, voice, bots.
PrincipalSession cookie or bypass linkOperator scope plus money approvals and admin purge.
RailsAuthorization: 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 s

Idempotency, 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 exists

Next 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

ContractRoleKey functions
DealRegistryStatus machine on chain; attestation required for VERIFIED; screening clear for FORWARDED; RAILS_ROLE reports.registerDeal, setStatus, recordAttestation, recordScreening, forward, railsReport
FeeEscrowMilestone fees M0–M3, broker flat fee, introducer chain (≤ 8), Chainlink Automation release, refund keeps M0.fund, fundWithChain, release, refund, checkUpkeep, performUpkeep, chainOf, chainTotal
AttributionAnchorAnchors receipt roots and onboarding hashes.anchor(bytes32)
PermissionedToken + IdentityRegistryERC-3643-style units; transfers gated by identity claims.mint, transfer (gated), freeze, clawback
AttestationRegistryEIP-712 bank attestations.attest, isAttested
SubscriptionDvPUnits 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.