Partner API
Your users borrow stablecoins at 0% interest against tokenized US equities, or lend into a tranched pool funded by systematic covered call premium. One API, non-custodial throughout, and you never touch a key.
# 1. Spout builds. Nothing executes.
POST /v1/borrow/loans
{ "asset": "AAPL", "collateral_amount": "45.4",
"borrow_amount": "4222.20" }
# response
{
"transaction": {
"unsigned": "AQABA0pWZmZmZm...b64",
"id": "txn_5pR8mK",
"expires_at": "2026-08-24T14:32:30Z"
},
"simulation": {
"summary": "Borrow 4,222.20 USDC against 45.4 AAPL"
}
}
# 2. Your wallet adapter signs it.
# 3. You submit it. Only now does the loan exist.
Read this first
Spout is a non-custodial protocol on Solana. The API never moves user funds and never holds a private key. If you have integrated a custodial fintech API before, this is the one structural difference that shapes your whole integration, so it is worth understanding before you read anything else.
POST /v1/borrow/loans returns an unsigned, base64 serialized Solana transaction and a
transaction id. No loan exists yet.
Your wallet adapter deserializes it, shows the user what they are approving, and submits it to Solana. Spout is not in this step.
Poll GET /v1/transactions/{id}, or take the transaction.confirmed webhook and
skip the polling entirely.
Two consequences worth internalising now.
Built transactions expire. They carry a recent blockhash. If your user takes too long to
sign, expires_at passes and the submit fails. Rebuild rather than retrying the stale one, and
never build a transaction and hold it while the user reads a confirmation screen.
A 200 means "here is a valid transaction", not "it happened." Nothing is real until it confirms on chain.
Every write endpoint that builds a transaction also returns a simulation block: a plain-language
summary and a list of before and after effects. Render that as your confirmation screen. Users who cannot read
what they are signing do not sign.
Quickstart
The sandbox is served from the specification itself, so it validates your requests the same way the live API will and returns the shapes the spec promises. During the preview any bearer token is accepted, but the header itself is still required: send it and a missing-key bug shows up now rather than on launch day.
# protocol state, a good call to make on app boot
$ curl -H "Authorization: Bearer sk_test_anything" \
https://sandbox.api.spout.finance/v1/protocol/stats
# what a partner sees before building a borrow screen
$ curl -H "Authorization: Bearer sk_test_anything" \
"https://sandbox.api.spout.finance/v1/assets?limit=5"
The repository carries the same sandbox, an annotated reference integration and an MCP server. It is private during the preview, so ask us for access and we will add you.
$ git clone https://github.com/SpoutFinance/spout-builders
$ cd spout-builders
$ npm install
$ npm run mock # terminal 1: the sandbox on 127.0.0.1:4010
$ npm run example # terminal 2: an annotated borrow flow against it
The example walks the full flow and prints what a real integration would do at each step, including the parts it cannot do without a wallet. It is written to be read as much as run.
Most integrations that fail in production fail because nobody exercised the error branches, and those are the
ones a user hits at the worst moment. The sandbox honours the Prefer header so you can force any
status or named example on demand.
$ curl -H "Authorization: Bearer sk_test_anything" \
-H "Prefer: code=422, example=protocol_paused" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: manual-test-1" \
-d '{"wallet":"7Np41oe...","asset":"AAPL","collateral_amount":"10","borrow_amount":"1000"}' \
https://sandbox.api.spout.finance/v1/borrow/loans
Named examples available on the 422: exceeds_max_ltv, insufficient_collateral,
asset_not_accepting_deposits, protocol_paused, stale_price_feed.
Concepts
None of these are API quirks. They are product facts that your UI has to reflect honestly, and each one has bitten someone.
The 0% is not promotional. Lender yield comes from covered calls written against the collateral, not from the borrower. But when a written call finishes in the money, the upside above the strike is given up, and that is a real cost to the borrower.
estimated_borrow_cost_apr on the asset object is our estimate of that cost, annualised from
historical cycles. It is an estimate, it is not charged, and it is not deducted from the balance.
Show it next to the 0% rate, never instead of it. Presenting 0% alone is misleading;
presenting the cost alone is also misleading.
Every partner arrives assuming one account-wide number. There is one health factor per loan, and liquidation reads only the loan that crossed its own 1.00. Drag the AAPL price and watch the NVDA rail stay exactly where it is.
Unaffected. A separate loan with its own collateral, its own 1.00 and its own liquidation. Nothing you do to the AAPL position moves this rail.
This differs from Aave, Compound and every margin account your users have used, which is exactly why
GET /v1/borrow/health returns an object with a positions array, one entry per
collateral asset. There is also an aggregate block for a summary line and it is documented as
display only. If you compute a single portfolio health number and present it as a safety signal, your users
will be liquidated while your UI says they are fine.
| Senior | Junior | |
|---|---|---|
| Yield | Lower | Higher |
| Loss position | Paid first | First loss |
| Instant exit | Yes, at a dynamic haircut | No |
| Queued exit | Free, at full NAV, 7 day notice | Free, at full NAV, 45 day notice |
Junior absorbs losses before senior and is compensated for it. There is no instant exit from
junior. Call POST /v1/lend/withdrawals with preview: true and show the user
the haircut or the estimated wait before they commit to anything. A lender who discovers a notice period
after clicking withdraw is a support ticket and a complaint.
APY figures are estimates from trailing realised cycles. Label them as estimates in your UI. They are not guarantees and they will move.
Conventions
Authorization: Bearer sk_test_... for the sandbox, sk_live_... in production.
Keys are environment scoped and never interchangeable, so a test integration cannot touch live state.
Every POST accepts Idempotency-Key. Replaying a key returns the original
response. Reusing one with a different body returns 409.
Generate the key where you decide to perform the action, not inside your HTTP helper. A key minted per request is new on every retry, which gives you no protection while looking like it does.
Decimal strings, never JSON numbers. "1250.75". Use a decimal library:
parseFloat will appear to work in testing and lose money in production.
Every amount is paired with an asset, and decimals sits on the asset object, so
the scale is never a guess.
Cursor based. starting_after walks forward, ending_before walks backward,
limit caps at 100.
There are no offsets, deliberately: offset pagination skips or repeats rows when the underlying set changes between pages, and it will change.
One envelope everywhere, with a stable code, a human message, the offending
param, a doc_url and a request_id.
Branch on code, never on message. Codes are enumerated in the spec; messages
are written for humans and will be reworded.
The version is pinned per key and overridable per request with Spout-Version. Additive
changes ship without a bump; breaking changes get a new dated version and the old one keeps working.
Every response carries Spout-Request-Id. Log it, and quote it when you ask us for help.
{
"error": {
"type": "invalid_request_error",
"code": "exceeds_max_ltv",
"message": "Requested borrow of 6000.00 USDC exceeds the maximum of 5277.75 at 50% LTV for AAPL.",
"param": "borrow_amount",
"doc_url": "https://spout.finance/docs/errors#exceeds_max_ltv",
"request_id": "req_9xKq2mB"
}
}
Webhooks
Wire loan.at_risk before anything else. It is the event that keeps your users from being
surprised, and it is the difference between a product that warns people and one that does not.
| Event | Fires when |
|---|---|
loan.at_risk | A loan's health crossed a warning threshold |
loan.liquidated | Collateral was partially or fully liquidated |
cycle.settled | An options cycle settled and lender yield was distributed |
withdrawal.available | A queued withdrawal is claimable |
transaction.confirmed | A transaction you submitted confirmed on chain |
Delivery is at least once and ordering is not guaranteed. Deduplicate on the
event's top-level id, and never infer state from the sequence you receive: re-read the object
instead. Verify the Spout-Signature header before you trust the body.
Your webhook endpoint is yours, not ours, so it does not carry a Spout bearer token. The signature is the authentication.
AI agents
If you build with Claude Code, Cursor or anything else that speaks MCP, run our MCP server locally and the agent can query the specification while it writes your integration. That beats pasting the spec into a prompt: the agent pulls only the endpoint it is working on, and it cannot work from a stale copy.
{
"mcpServers": {
"spout": {
"command": "node",
"args": ["/absolute/path/to/spout-builders/mcp/server.js"]
}
}
}
| Tool | What it does |
|---|---|
integration_notes | The things integrators get wrong. Have the agent read this first. |
list_endpoints | Every operation including the inbound webhooks, filterable by tag. |
get_endpoint | One operation with references resolved inline. A bare path returns every method on it. |
get_schema | A named object shape: Loan, Asset, Tranche, HealthSummary, Error. |
list_error_codes | The stable error enumeration to branch on. |
search_spec | Full text search when you know the term but not where it lives. |
Agents that prefer documents to tools can read llms.txt or fetch openapi.yaml directly.
Next
Every operation, parameter, schema and error, generated from the specification.
OpenAPI 3.1. Generate a client, load it into Postman, or diff it when we version.
Protocol state, your keys, the request log and webhook config, in one place.
Integration questions, missing endpoints, or a shape that does not fit your product.