One API for DeFi yield — with the platform to match.
Fintechs are sitting on idle stablecoin balances that could be earning onchain yield. Almost none has the blockchain team it takes to integrate and leverage the right protocols. This is the missing layer: one REST API that turns the largest DeFi lending protocols — into something any developer can integrate in an afternoon, with the keys, webhooks, sandbox, and docs to prove it.
- Product judgment under real constraints. It's built around where API integrations actually die — auth and key rotation, event delivery, rate limits, sandboxes that lie — the checklist a decade of running Square's developer platform teaches, applied to a brand-new domain.
- Full-stack range with AI-native tools. Solo and end to end: the gateway, a full token lifecycle, Svix webhooks, request logs, SDKs, and a docs site with live playgrounds — 444 commits, ~50k lines, public and MIT-licensed.
- An honest register. The repo relabeled itself a reference implementation and stripped its own inflated claims; every number on this page is repo-citable, and the dead ends are documented next to the wins.
The problem
Onchain lending pays 3–8% on USDC. The two largest protocols alone — Aave (≈$14.6B in deposits) and Morpho (≈$11.8B, both mid-2026) — hold more capital than most banks, and every fintech with idle customer balances wants a piece of it. Regulation just made the shape of the answer specific: the US GENIUS Act and the EU's MiCA both bar stablecoin issuers from paying interest directly, so the yield has to come from a separate service layer. An API. A category that, so far, has been led largely from Europe.
The supply side is hostile. Every protocol is its own smart contracts, its own chains, its own rate semantics and failure modes. Integrating one is a project; integrating several is a team — and that's exactly the team almost no fintech has.
The naive answer is "just wrap the protocols." It misses where integrations actually die. Not on the happy path — on auth and key rotation, on event delivery, on rate limits, on environments that lie, on debugging someone else's 401 at 2 a.m. The unglamorous parts are the product.
I spent a decade running developer-platform infrastructure at Square, which teaches you precisely which parts those are. This project applies that checklist, end to end, to a domain that has never had it. The craft is old; the domain is new.
Who it's for
The founder about to bet a company on this space. Before the term sheets, someone has to know whether "yield as an API" is actually buildable by a small team — and where the dragons are. A slide can't tell you. A Figma can't tell you. Building the entire surface, end to end, is the only honest way to find out. That's the true origin of this project: a working answer to "how hard is this, really?"
The fintech developer. They will judge the platform in the first fifteen minutes: how fast to a key, how good the errors, whether the sandbox lies. Every decision below is aimed at those fifteen minutes.
The platform engineer. They want a reference for how the pieces — tokens, webhooks, limits, logs, access control — compose in real code they can clone and run. The repo is public and MIT-licensed for exactly that reason: 444 commits over 72 active days, ~50k lines, with the dead ends left in. Its last act before this page was relabeling itself a reference implementation, which is what it is — a demonstrative build, not a company.
Product journey
From zero to yield, in five beats
- Sign up, get keys, instantly — a self-serve account, an application, and a tbd_sand_… key in under a minute. No sales call, no human.
- See real rates in one call — GET /v1/yield/rates returns live APY from Morpho and Aave, fetched in parallel: ~200 ms today, down from 2,382 ms.
- Move (pretend) money — deposits and withdrawals run against the sandbox and balances update. No real funds move, ever — verifiable in the code, where the onchain calls exist and are deliberately never wired in.
- Get told, don't poll — Svix-delivered webhooks with signatures and retries, plus a portal that shows every delivery and lets you replay failures.
- See everything you did — 7-day request logs with status, latency, and filters, and rate-limit headers on every response so you're never surprised.
The sequence is deliberate. A developer decides whether to bet on a platform in their first session, and the beats are ordered by what they'll actually do: get a credential, see real data, write something, get told about it, and then check the tape when something looks off. Every beat works today against the live sandbox — the same one linked at the top of this page — and the first beat costs less than a minute because nothing in it waits for a human.
Design principles
These aren't aspirations — each one is checkable in the repo.
1 · The contract is the product. Stripe-style structured errors — { error: { code, message, type, param } } — are in the very first commit, not a retrofit. Versioned paths (/v1/), environment-prefixed keys, rate-limit headers on every response. The parts of an API you can't change later were decided first.
2 · Buy the undifferentiated, build the differentiating. Webhook delivery went to Svix — retries, signatures, and a debugging portal for one dependency. Access control was built by hand, because "users span businesses" was the differentiator. Both calls get their own deep-dives below.
3 · Secure by default, even in a prototype. 15-minute access tokens with rotation. Refresh tokens stored only as HMAC-SHA256 hashes. BCrypt passwords. Login limited to 5 attempts per minute by IP, with account lockout. Wallet keys under AES-256-GCM envelope encryption. One hardening commit reads like a checklist of things prototypes skip.
4 · The sandbox must not lie. Same API, same shapes, Sepolia instead of mainnet, keys that say sand in the credential itself, test fixtures in one call — and user-facing "staging" was renamed "sandbox" everywhere, because developers trust the word.
5 · No real funds move — as an engineering decision, not a disclaimer. The mainnet contract clients exist in the codebase, with real addresses, and are deliberately never called from the money paths. The safety property is structural, not a config flag.
6 · Say what's true. The repo's last act was relabeling itself a reference implementation and stripping its own inflated claims. This page inherits that standard: every number below is repo-citable, and the dead ends are documented next to the wins.
Architecture & tech
A vanilla-JS frontend on Cloudflare Workers serves the docs and dashboard. The gateway is Kotlin/Ktor on Railway: a fixed middleware pipeline — CORS, rate limiting, async request logging, auth — in front of ~80 route handlers, all speaking the same Stripe-style error envelope with a req_ ID minted per request. Services sit on PostgreSQL (18 tables, code-first Exposed schema, HikariCP). Yield data comes from Morpho Blue and Aave V3 over GraphQL, fetched in parallel with retry and backoff. The Web3j contract clients for both chains are present — and unwired from every money path.
rate-limit headers on every response
Yield Accounts · embeddable
Cloudflare Workers
→ request log (async)
→ auth → routes
+ 30-day rotating refresh
PermissionService · RCAC
read < write < admin
EncryptionService · AES-256-GCM
YieldService · Morpho + Aave
RequestLogService
+ 6-hourly cleanup
unwired from money paths
Monitoring & performance
A developer platform is judged on the days it fails — so the observability was built before there were any developers to observe. Every request is logged asynchronously with its duration; every response carries X-RateLimit-* headers (100/min default, 5/min on login, 300/min on health) and a Retry-After on 429; Sentry is running in production, sampling 20% of transactions there and 100% in the sandbox.
The 10× commit
The best performance story in the repo is one commit. The first version of /v1/yield/rates asked Morpho, waited, then asked Aave — and each client carried its own generous retry policy, so a slow upstream didn't just add its own latency, it multiplied it. Total: 2,382 ms for two GraphQL calls. The fix was almost embarrassingly small: fetch both protocols in parallel and tighten retries to two attempts with a 50 ms initial backoff. Same data, ~200 ms. The lesson wasn't "go parallel" — it was that latency compounds at the seams between services, and you find it by measuring, not by reading the code.
Health is layered: a /health endpoint, a Docker HEALTHCHECK every 30 seconds, Railway's own healthcheck path, and restart-on-failure capped at ten attempts. The live sandbox answers /health in 0.2–0.3 s from a cold external client — an anecdote, not a benchmark. On Railway, the API service has been deployed 431 times since November 2025 (39 failed builds — a 91% success rate), and the Postgres instances have run untouched since January 2026. Upstreams age too: in mid-2026 the Morpho API renamed a field and half the rates quietly zeroed — found and fixed while preparing this page, which is exactly the kind of decay a rates API signs up for.
Honest scope: the rate limiter is a single-node, in-memory sliding window — the architecture doc names Redis as the scale path. /health deliberately skips the database check. Request and response bodies are deliberately not stored. And the logs themselves are a product feature, not just ops — they get their own deep-dive below.
Challenging features
Eight problems carried most of the engineering weight. For each: the problem, the naive approach, why it broke, where it landed.
1 · Documentation developers — and now machines — can actually use
The docs shipped as a full system, not a README: quickstart, getting-started, guides, an API reference with interactive per-endpoint pages (~26 endpoints with parameters, curl samples, and live environment-aware examples), an SDKs page with live code playgrounds, and a status page. One toggle flips every code sample on every page between production and sandbox URLs and keys.
The SDKs exist for a specific reason: the three embeddable TypeScript components (Applications, Wallets, Yield Accounts) let a partner mount whole platform surfaces as drop-in components instead of rebuilding CRUD UIs against the raw API. Honest scope: they're handwritten iframe-embed components with live playgrounds — not generated client libraries, and not published to a package registry. The docs say what they are.
Then 2026 moved the bar: developers' coding agents now read docs before humans do, and the industry answer — llms.txt plus an OpenAPI spec — is being shipped by Stripe, Anthropic, Vercel, and Cloudflare. This repo's docs were human-excellent and machine-opaque: the endpoint catalog lived in bespoke JS. So the gap got closed before this page shipped — an OpenAPI 3.1 spec covering the documented endpoints and an llms.txt at the docs root, linked from the reference. The honest version of "AI-ready docs" is shipping them, not claiming them.
2 · Defining the API — and renaming everything, twice
The whole platform landed in one initial commit with Stripe's error taxonomy already in place — the API contract was designed before the product had a name that stuck. Then reality: the project was rebranded twice, and each stratum is still visible at HEAD — the flow-api/ directory from the first name, the tbd_ key prefixes from the second, three brands fossilized in one codebase. I'm keeping it; it's true, and it's the most honest diagram of how products actually get named.
What matters is what didn't move. The error envelope, the /v1 versioning, and the environment-in-the-credential key design are commit-one decisions that survived every rename — and survived the one genuinely disruptive pivot, when the resource model split "accounts" into users and businesses to make room for the access-control system in deep-dive 7. That's the test of a contract: the product changed names twice and changed shape once, and no integration would have broken.
The dead ends are instructive too. Custom API domains were attempted and reverted twice ("not configured yet" — the honest URL is the Railway one). And the day-one spec documented cursor pagination that was never implemented: documentation fiction, the exact failure a contract-first culture is supposed to prevent, sitting in my own spec. It's called out here because a platform that will lie to you about pagination will lie to you about anything.
3 · Webhooks — the build-vs-buy call
An API that only answers questions forces developers to poll; events are table stakes. The repo has the fossil of the naive approach: a webhook_url column, a whsec_ secret, and a /v1/webhooks stub that returned [] for eight weeks. It stayed a stub because reliable delivery is not a POST — it's retries with backoff, dead-letter queues, signing and replay protection, SSRF defense, per-endpoint logs, and a UI where customers debug themselves. Each of those is a project.
Sending an HTTP POST is easy; delivering it reliably at scale is not — and delivery isn't the differentiator of a yield API. So the call was to buy: one Svix integration (+1,594 lines) traded all of those projects for one dependency and one env var — seven event types registered on boot, one Svix application per account, HTTPS-enforced endpoints, test sends, and the App Portal embedded so every developer gets delivery logs and manual replay without me building a debugging UI. The economics only work because the boundary was drawn where the differentiation ends: events are table stakes, so they're bought; access control is the product's edge, so it's built.
Battle scars, for honesty: the Svix SDK 1.30 upgrade broke and was reverted to 1.16, and only four of the seven event types have live triggers today — the other three are declared but not yet fired. The screenshots below are real deliveries against the live sandbox, signed and acknowledged.
4 · A sandbox that tells the truth
A developer's first real action is against the sandbox. If it lies — different shapes, stale data, mystery auth — they leave and never tell you why. So the sandbox here is not a flag in one database: it's two parallel Railway deployments, each with its own Postgres, its own env vars, its own /health. The ENVIRONMENT variable drives Sepolia (chainId 11155111) versus mainnet (chainId 1), and keys are born environment-scoped with the environment readable in the prefix.
One service call bootstraps a test app, wallet, yield account, and a never-expiring test token — resettable fixtures, one request. The docs flip every example between environments with a single toggle, so what you read is what you'll call. (Full disclosure: the Sepolia contract addresses are still zeroed placeholders — which is fine, because nothing calls them; the sandbox's truth is in its shapes and behavior, not in testnet theater.)
The semantic core: deposits update balances and fire the same webhooks production would, while the real contract clients sit unwired. And one naming decision carried product thinking: user-facing "staging" was renamed "sandbox" everywhere — staging is yours; a sandbox is theirs.
5 · App creation, and everything a key carries
The self-serve chain is account → application → API key, with no human in the loop. Each key is born with an environment, an optional expiry, and a permissions column; each application gets its own encrypted Ethereum wallet (AES-256-GCM under a master-key envelope) provisioned at creation.
Rate limiting rides the same identity: per-endpoint limits keyed by credential — except auth routes, which are always keyed by IP, because brute force is an IP problem, not a key problem. The middleware pipeline is the request's whole life: CORS, rate limit, log, auth, handler, with a req_ ID minted for every request.
Honest notes: per-key permissions are stored but not yet enforced — enforcement lives in the access-control system's resource grants, and the two models coexist (deep-dive 7). And application keys are stored plaintext in the database today; the refresh tokens got the hashing treatment first.
6 · Watching your own API — logging as a product feature
A decade of platform work teaches one social fact: developers don't file support tickets, they file "your API is broken" — and the logs are how you (and they) find out whose code it actually was.
Every request is logged asynchronously to Postgres — method, path, status, duration_ms, IP, user agent, and the application and environment resolved from the key — and surfaced in the dashboard with 7-day stats (success rate, average latency, errors-24h) and filters by app, environment, status class, and method.
Retention is policy-in-code: 7 days, enforced by a cleanup job every six hours, and the dashboard says so in plain words — "Logs are retained for 7 days" — instead of letting developers discover the horizon by falling off it. Pagination defaults to 50 rows, caps at 100; the logger skips /health so uptime probes don't drown the signal.
The deliberate omission: request and response bodies are captured as null — the columns exist, and the code writes null into them on purpose. Privacy and payload discipline over debugging convenience.
7 · Access control — the big one, designed vs. shipped
The design prompt, paraphrased from the original brief: businesses are accounts on whitelabeled developer infrastructure; roles are arbitrary groupings of permissions; every resource carries read/write scopes; and — the differentiator — users span multiple businesses, so a contractor or auditor is one identity with explicit grants everywhere, not five silo accounts.
What shipped, in four commits over two days (~5,000 lines): a system I called RCAC — resource-centric access control. Resources are autonomous, owned by no business. Every access is an explicit row in resource_access — granted to a user or to a role, never both — on a read < write < admin ladder, optionally environment-scoped. Five system roles (OWNER through VIEWER) seed every business, plus arbitrary custom roles. Business creation bootstraps the admin. An audit log records every permission change. A business switcher sits in the dashboard nav, and cross-business grants work at the API.
Of the original prompt, the ledger reads: users spanning multiple businesses — shipped, in the schema and at the API. Roles as arbitrary permission groupings — shipped. Read/write scopes on every resource — shipped. Admin bootstrap, audit trail, cross-business grants — shipped. What didn't: the legacy account model was never fully migrated, so two auth systems coexist at HEAD and some legacy routes still assume the old model; the "edit member roles" modal never shipped; and RCAC refresh tokens were generated but never stored — a TODO that outlived the project, closed only while preparing this page (the token deep-dive, next, describes the finished state).
The senior-engineer takeaway isn't the feature list. It's that access control is the one feature you cannot bolt on — it forces the account-model rewrite, the migration service, the dual-auth period, all of it — and this repo is a preserved specimen of exactly what "halfway through the auth migration" looks like. That's worth showing, because it's what most real systems look like on any given Tuesday.
8 · Tokens that expire in 15 minutes — and developers who never notice
Short-lived tokens are the cheapest way to shrink blast radius, and the fastest way to infuriate a customer: naive 15-minute expiry means every developer's session dies mid-work, and security that punishes the customer gets disabled. Both halves had to ship together.
The server half is one hardening pass that reads like a checklist of things prototypes skip: access tokens cut to 15 minutes; refresh tokens rotated on every use — single-use, by construction — and stored only as HMAC-SHA256 hashes, so a leaked database row is just a hash; BCrypt passwords; login rate-limited to 5 attempts a minute by IP; account lockout; AES-256-GCM envelope encryption for wallet keys. The RCAC user flow now runs the same pattern end to end — persisted, hashed, rotating refresh tokens with the same 15-minute TTL.
The client half is TokenManager.js, a small vanilla-JS layer that owns the token lifecycle so page code never sees it. It schedules refresh before expiry, retries a 401 exactly once after refreshing, and queues concurrent requests behind a single in-flight refresh — the subtle bug, because rotation makes the second simultaneous refresh a hard failure. When refresh is truly dead, it logs out cleanly instead of stranding the page. The result: the security posture of a bank, felt by the developer as nothing at all.
One aside the git log insists on: before any of this could matter, the first week was ~21 commits of fighting Docker, Railway, and DATABASE_URL formats before the API served its first request. The platform builder experiencing exactly the onboarding friction platforms exist to remove — which is as good a mission statement as any.
Selected screens