Marketplace API
Every listing, its terms and its live economics as JSON — no key, no account, no browser. This is the same data the marketplace itself renders.
Building with an agent? Paste this in.
Everything a coding agent needs to integrate correctly on the first attempt: the base URLs, where to start, and the nine rules that are not guessable from the field names. The rest of this page is the same material for a human.
Integrate the LienFi marketplace API — tokenized US tax lien certificates and
redeemable tax deeds, settled in USDC on Base.
The public research API needs no API key, account or Authorization header.
Wallet-scoped MCP tools use a bearer authorization issued at
https://app.lienfi.com/agents/authorize. Read these first:
OpenAPI 3.1 https://api.lienfi.com/api/v1/openapi.json
Conventions https://app.lienfi.com/llms.txt
Reference https://app.lienfi.com/docs
BASE URLS
https://api.lienfi.com/api/v1
The complete record. Every response is wrapped in
{ success, data, meta }. NOT wildcard-CORS — call it server-side.
https://api.lienfi.com/api/public
A curated projection: flat camelCase, no envelope, an explicit field
allowlist, no wallet addresses. Sends Access-Control-Allow-Origin: *.
POST https://api.lienfi.com/api/v1/mcp
Model Context Protocol, JSON-RPC 2.0 over stateless HTTP. Public research:
market_overview, search_liens, get_lien. Bearer-scoped: agent_status,
quote_lien, my_positions, and the purchase loop prepare_purchase →
confirm_purchase → report_purchase, which
returns UNSIGNED transactions for the agent's own wallet. LienFi holds no
key and submits nothing. Walkthrough: https://app.lienfi.com/docs#mcp-walkthrough
START WITH
GET https://api.lienfi.com/api/v1/liens/facets
Which states and counties actually have inventory, and the real numeric
bounds. Call this before filtering, or an empty page is ambiguous.
GET https://api.lienfi.com/api/v1/liens?states=FL&max_price=50000&sort_by=apy_high_low&limit=5
The listing book. ~30 optional query params. limit caps at 100.
GET https://api.lienfi.com/api/public/liens/{id}
One lien, flat, carrying the NET per-year rate. Start here unless you
need the full record.
RULES THAT ARE NOT GUESSABLE FROM THE FIELD NAMES
1. Every yield on the versioned tree is GROSS of our fee. calculated.apy also
compounds, and has no consumer in our own interface. The net figure is
netPerYearPercent on the curated tree, and it is what sort_by=apy_high_low
ranks on. Do not describe "the yield" without saying which one.
2. The fee is taken from the GAIN over what the buyer paid, floored at zero —
never from the redemption value. A lien redeeming below its purchase price is
charged nothing. Read the live rate from meta.fee_config; do not hardcode it.
3. redemptive_value, accrued_interest and listing_price on the raw row are frozen
snapshots, written at create/update time, and stale on essentially every row.
The calculated block is recomputed on read — use
calculated.current_redemptive_value and calculated.current_listing_price.
4. Rates are annualized SIMPLY (return * 365 / days), not compounded: a
certificate pays once at redemption and there is nothing to reinvest. Under 30
days to maturity there is no per-year rate at all — expect null, never 0.
5. redemption_deadline is the statutory deadline and is immutable onchain from
the moment the lien is minted. Never recompute or infer it.
6. An unknown query parameter is DROPPED, not rejected. ?max_prise=50000 answers
200 with success: true and the entire unfiltered book. Nothing in the response
reports the filter you meant to send — watch meta.pagination.total move.
7. status=listed also matches relisted; statuses=listed is taken verbatim and
silently returns fewer rows. Send statuses=listed,relisted for everything on
offer.
8. A 400 arrives in two different shapes. Schema-level failures (limit over 100,
a bad enum, a malformed id) carry no success key at all; policy failures use
the envelope. Branch on the HTTP status code, not on body.success.
9. Price filters and price sorts run in the database against the stored columns
in rule 3, so max_price and price_low_high can disagree by a few dollars with
the figure you then read out of calculated. The discount_* and apy_* sorts do
not have this problem.
A tax lien is not a deposit and not a bond. Redemption is the expected outcome,
not a guaranteed one, and the remedy otherwise is foreclosure on the property —
a remedy that runs with the underlying lien or deed under state law and is not
necessarily a right exercisable by a token holder. Do not present it to a user as
a step they can take.
Read the risk disclosures in https://app.lienfi.com/legal/terms-and-conditions, sections 4 and 5,
before presenting any of this as an investment recommendation.Overview
One marketplace, three ways to read it. Public research needs no API key, account or Authorization header. The few wallet-scoped operations that need identity say so where they are documented.
- REST. Two trees — the complete versioned record, and a curated flat projection for consumers that are not browsers. Start at
GET /liens/facets, thenGET /liens. - OpenAPI 3.1. Built from the routes themselves, so it cannot describe a parameter the API does not accept. Point a generator at it rather than hand-writing a client.
- MCP. If your client speaks Model Context Protocol, prefer it: its
search_liensranks on net yield using the same arithmetic this site displays, so you get our numbers rather than your reconstruction of them.
The book runs to 250+ live tax lien certificates and redeemable tax deeds. Terms are set by state statute and differ by state, so which states are represented changes with the inventory — ask /liens/facets rather than trusting a list on a page, and read the statutory mechanics for the rules each state sets.
Base URLs
Two trees, on purpose. Which one you want depends on whether you are calling from a browser and whether you need the complete record.
https://api.lienfi.com/api/v1https://api.lienfi.com/api/publicThe versioned one is the complete record and uses a { success, data, meta } envelope like every other REST route here. The /api/public tree is a curated projection for consumers that are not browsers: flat camelCase, no envelope, an explicit field allowlist, and no wallet addresses or transaction history. public there is a promise about the contract, not a version, which is why it does not sit under one.
Quickstart
Three calls that cover most of what anyone builds.
# Texas liens under $50k, best net per-year rate first
curl -s 'https://api.lienfi.com/api/v1/liens?states=TX&max_price=50000&sort_by=apy_high_low&limit=5'
# What is actually in stock, before you filter blind
curl -s 'https://api.lienfi.com/api/v1/liens/facets'
# One lien, flat, no envelope
curl -s 'https://api.lienfi.com/api/public/liens/<id>'There is nothing to sign up for and nothing to send in a header. If you get a CORS error, you are calling the versioned tree from a browser — see Limits, CORS and caching. The one call that can still refuse you is buy-price, which needs the buyer's wallet to belong to a consenting account.
Reading the numbers
Four things that are not guessable from the field names. Each one has been got wrong before.
Every yield this API returns is gross
calculated.apy and dynamic_blended_apy are both gross of our fee, and apy additionally compounds. Neither has a consumer in our own interface, and neither is the number we show a buyer. The net figure is on https://api.lienfi.com/api/public/liens/{id} as netPerYearPercent, and it is what sort_by=apy_high_low ranks on.
The fee is charged on the gain, not on the value
A buyer pays listing_price and nothing else. At redemption the vault takes interest_fee_bps of the gain over that purchase price — floored at zero, so a lien redeeming below what the buyer paid is charged nothing. At 1000 bps, a $1,000 lien redeeming at $1,100 is charged $10 and the buyer keeps $1,090.
Three columns on the raw row are frozen snapshots
redemptive_value, accrued_interest and listing_price are written at create and update time and are stale on essentially every row — Florida interest accrues monthly, so a row drifts from the day it is written. The calculated block is recomputed on every read. Use calculated.current_redemptive_value and calculated.current_listing_price.
One consequence to plan around: the filters and the price sorts run in the database, on those same stored columns. So max_price, min_ltv and price_low_high can disagree slightly with the figure you then read out of calculated — a lien can sit a few dollars either side of a bound you set. The discount_* and apy_* sorts do not have this problem; those are ranked on the recomputed values.
Per-year rates annualize simply, and stop under 30 days
Every per-year figure we publish is return × 365 / days, not a compounding rate: a certificate pays once at redemption and there is nothing to reinvest. Below 30 days to maturity we publish no per-year rate at all rather than a large one — there is no year there to restate over — and netPerYearPercent is null for those liens, never 0.
One more, if you are pricing rather than screening: redemption_deadline is the statutory deadline and is immutable onchain from the moment a lien is minted. It is not recomputed and must not be inferred from an issue date and a term.
TypeScript SDK
If you are already in TypeScript, this is the shorter path to the same API.
npm i @lienfi/sdk
npm i viem # only for the onchain helpers@lienfi/sdk on npm — the full reference, including the purchase rules that cost money to get wrong.
import { LienFiClient, buildPurchaseCalls } from '@lienfi/sdk';
const client = new LienFiClient();
// Same rows, same `calculated` block, as GET /liens — typed.
const { liens, feeConfig } = await client.listLiens({
states: 'TX', max_price: 50000, sort_by: 'apy_high_low', limit: 5,
});
// null means we could not read FeeManager. It does NOT mean zero bps.
if (!feeConfig) throw new Error('no fee rate; do not rank on net');
// Bound to this buyer for 300s. Fetch it immediately before you send.
const quote = await client.getBuyPrice(liens[0].id, buyer);
const calls = buildPurchaseCalls(quote, { chainId: 8453 });
// calls.approve then calls.buy. approvalAmount === amountActuallySpent,
// so no surplus allowance survives a buy that reverts.LienFiClient covers the public read surface with no key and no header, and the call builders construct the approve and buy for a purchase. It is the same purchase flow the LienFi marketplace itself runs, so an integration behaves identically rather than drifting from a re-implementation.
Zero runtime dependencies. viem is an optional peer — the whole REST client works without it, and the builders return plain call descriptors, so nothing in the package imports viem at runtime.
Valuations are not recomputed in the package. Every figure comes off each row's calculated block, which is where the API computes them — so the SDK, this page and the marketplace cannot report different money for one lien. Read Reading the numbers first; all four caveats there apply here unchanged.
Source-available under PolyForm Shield 1.0.0 — build a fund, vault, keeper, analytics product or agent on LienFi freely, including modifying and redistributing it. The one excluded use is a product that competes with LienFi.
Endpoints
The whole public read surface. The write paths, everything under /portfolio and the admin tree need an authenticated session and are not documented here.
GET/liens
https://api.lienfi.com/api/v1/liens
The listing book: filtered, sorted, paginated.
Every parameter in the Query parameters section applies to this route, and every one of them is optional. The defaults return what is currently for sale, newest first, twenty to a page.
This is the only route that carries the fee rate in meta.fee_config for a whole page of liens at once — read it from there rather than assuming 1000 bps.
Response
An array of full lien records at data, plus pagination, filters and fee config at meta.
curl -s 'https://api.lienfi.com/api/v1/liens?states=TX&max_price=50000&sort_by=apy_high_low&limit=5'GET/liens/facets
https://api.lienfi.com/api/v1/liens/facets
Distinct filter values and the real numeric bounds of live inventory.
Call this before guessing which states or counties have stock. It is the difference between an empty page that means "nothing matched" and one that means "you filtered on a county we have never listed".
Response
The distinct states, counties, municipalities and property types on the live book, plus min/max for price, interest and LTV.
curl -s 'https://api.lienfi.com/api/v1/liens/facets'GET/liens/{id}
https://api.lienfi.com/api/v1/liens/{id}
One lien in full — every public column, plus the live calculated block.
Parameters
idpath · uuid · requiredThe lien id. A malformed one is a 400, not a 404.
Response
The complete record, including calculated (recomputed on read) and the two redemptive-value projections.
curl -s 'https://api.lienfi.com/api/v1/liens/{id}'GET/liens/{id}
https://api.lienfi.com/api/public/liens/{id}
The same lien as flat camelCase JSON with no response envelope. Start here.
The investment facts on an explicit allowlist — no wallet addresses, no transaction history, one nesting level. It is also the only REST route that hands you the NET per-year rate rather than leaving you to apply the fee yourself; over MCP, search_liens ranks on the same figure.
netPerYearPercent is null, never 0, for a lien with under 30 days remaining, no cost basis, or a row that cannot be priced.
listingType says whether the asking price MOVES. par (nearly the whole live book), discount and premium are all recalculated from the live redemptive value on every quote, so the price climbs with accrual; only fixed is a flat amount that stays put. It is null on a lien that is not on offer, because the column is not cleared when a listing closes.
Parameters
idpath · uuid · requiredThe lien id.
Response
A flat object, or { error, message } with a 404 / 500. No success key either way.
curl -s 'https://api.lienfi.com/api/public/liens/{id}'GET/liens/map
https://api.lienfi.com/api/v1/liens/map
The same filter surface as /liens, unpaginated, as slim location records.
It accepts page and limit and ignores them — deliberately, so that the map and the table can never disagree about which liens matched a filter.
Response
A data.locations ARRAY of slim location records — id, coordinates, price and status. Note the extra nesting: data is an object here, not the array itself, which is the opposite of every other list route on this tree.
curl -s 'https://api.lienfi.com/api/v1/liens/map'GET/liens/{id}/buy-price
https://api.lienfi.com/api/v1/liens/{id}/buy-price
A signed EIP-712 quote for a named buyer address.
Read-only, but it is the purchase path: the signature binds a price and a maturity that the marketplace contract checks onchain. A quote is minted for the address you name and for no other.
THE ONE ROUTE ON THIS PAGE THAT CAN REFUSE YOU. It takes no Authorization header from a person, and it still answers 403 { code: "consent_required" } unless the buyer address is linked to a LienFi account that has accepted the current agreements — an unrecognised wallet fails closed. So the example below returns 403, by design: quoting a price to a wallet we cannot show has agreed to the terms is the thing the gate exists to prevent. Screening and pricing need none of this; only minting a signature does.
It can also answer 451 { code: "sanctioned_address" } — the buyer is screened against the Chainalysis sanctions oracle before any signature is minted — or 503 { code: "sanctions_screening_unavailable" } if that screen cannot be run at all. The 503 is not a transient server fault to retry blindly past: the gate fails closed, so “we could not check” refuses rather than passes. Both are minting-only, like the consent gate above; screening and pricing are untouched.
The signed maturity is the lien’s stored redemption_deadline, which is immutable onchain from the moment the lien is minted. It is not recomputed, and a quote carrying a "fresher" one would revert.
One case DOES take a header: a buyer bound to an agent authorization (POST /agents/register) gets a signed quote only with that authorization as Authorization: Bearer <blob> — the same bearer the MCP endpoint takes — and answers 401 { code: "authorization_required" } without it. A signed quote for such a wallet is also a reservation of its one in-flight slot (a second signed quote for another lien inside the window answers 409 { code: "purchase_in_flight" }), which is why the bearer is required: without it anyone who knew the address could hold that slot. intent=indicative prices without signing or reserving and intent=preflight runs every gate without either; neither needs a bearer.
Parameters
idpath · uuid · requiredThe lien id.buyerquery · address · requiredThe wallet that will submit the purchase,0x-prefixed.intentquery · signed | indicative | preflightDefaultsigned.indicative: the price only, nothing signed or reserved.preflight: every gate a signed quote applies, still nothing signed or reserved.
Response
The typed data, the signature, lienPrice and totalAmount in micro-USDC. Approve totalAmount — it carries the listing fee, which the contract never transfers but the ERC-20 approve must still cover.
curl -s 'https://api.lienfi.com/api/v1/liens/{id}/buy-price?buyer=0x0000000000000000000000000000000000000000'GET/liens/{id}/street-view
https://api.lienfi.com/api/v1/liens/{id}/street-view
A cached Street View image URL for the property, if one could be resolved.
Parameters
idpath · uuid · requiredThe lien id.
Response
An image URL, or a null-ish payload for a property with no resolvable panorama.
curl -s 'https://api.lienfi.com/api/v1/liens/{id}/street-view'GET/liens/{id}/static-map
https://api.lienfi.com/api/v1/liens/{id}/static-map
A cached aerial map image URL for the property.
Parameters
idpath · uuid · requiredThe lien id.
Response
An image URL.
curl -s 'https://api.lienfi.com/api/v1/liens/{id}/static-map'GET/market/activity
https://api.lienfi.com/api/v1/market/activity
Recent sale results — what filled, and at what price.
No wallet addresses. This is the endpoint for a historical track record; GET /liens is not, because it filters out expired certificates whatever status you ask for.
Parameters
limitquery · integerEvents to return, 1–25. Defaults to 10.include_summaryquery · booleanAdds lifetime sale totals tometa. Defaults to false.
Response
Recent fills with their prices and lots.
curl -s 'https://api.lienfi.com/api/v1/market/activity?limit=10&include_summary=true'GET/legal
https://api.lienfi.com/api/v1/legal
One legal document.
Six valid types; the ones to read are terms-and-conditions (which carries the risk disclosures in sections 4 and 5) and privacy-policy.
An unpublished type answers 200 with empty content and version 0 rather than 404 — so check version before rendering, or you will publish a blank document as though it were the terms.
Parameters
typequery · enum · requiredThe document slug, e.g.terms-and-conditions.
Response
The document body, its version and its effective date.
curl -s 'https://api.lienfi.com/api/v1/legal?type=terms-and-conditions'GET/openapi.json
https://api.lienfi.com/api/v1/openapi.json
This API as OpenAPI 3.1, built from the routes themselves.
Point a generator at it rather than hand-writing a client. It is built from the same schema blocks the routes validate against, so it cannot describe a parameter the API does not accept.
Response
An OpenAPI 3.1 document, roughly 30 KB.
curl -s 'https://api.lienfi.com/api/v1/openapi.json'POST/mcp
https://api.lienfi.com/api/v1/mcp
The Model Context Protocol endpoint. JSON-RPC 2.0; public research needs no credential and wallet-scoped tools use a bearer authorization.
Covered in full in its own section below. Listed here because it is part of the same public surface and answers on the same base URL.
Response
A JSON-RPC 2.0 response. Not the REST envelope — the client here is an MCP implementation.
Query parameters
GET/liens and /liens/map take the same set. Every parameter is optional. Where a plural and a singular form both exist, the plural is comma-separated and wins outright if you send both.
A misspelled parameter is silently ignored
Unknown query parameters are dropped, not rejected. So ?max_prise=50000 answers 200 with success: true and the entire unfiltered book — which reads as a filter that matched everything rather than as a typo. Nothing in the response will tell you either: meta.filters echoes only status, the singular state and county, and the price and interest ranges, so a working property_types is just as absent from it as a misspelled one. Watch meta.pagination.total move instead, and check names against the tables below.
Paging
The book runs to 250+ rows, and this endpoint never returns it in one response.
| Parameter | Meaning |
|---|---|
pageinteger · default 1 | Page number, 1-based. |
limitinteger · default 20 | Rows per page. Caps at 100 — a larger value is a 400, not a clamp. |
Price, value and rate
Bounds are inclusive. A minimum above its own maximum is a 400, not an empty page.
| Parameter | Meaning |
|---|---|
min_pricenumber | Minimum asking price, USD. |
max_pricenumber | Maximum asking price, USD. |
min_face_valuenumber | Minimum face value — the delinquent tax the certificate was struck for, not what it sells for. |
max_face_valuenumber | Maximum face value, USD. |
min_assessed_valuenumber | Minimum county-assessed value of the underlying property, USD. |
max_assessed_valuenumber | Maximum county-assessed value, USD. |
min_interestnumber | Minimum statutory interest rate, percent. A Texas redeemable deed carries no interest rate — it has a § 34.21 premium instead — so any lower bound above zero excludes the entire Texas book. |
max_interestnumber | Maximum statutory interest rate, percent. |
min_ltvnumber | Minimum loan-to-value, percent. Accepts 0–999.99; that ceiling is the column width, not a business rule. |
max_ltvnumber | Maximum loan-to-value, percent. |
Location
Call /liens/facets first — it returns the states and counties that actually have inventory, so you are not guessing.
| Parameter | Meaning |
|---|---|
statestring | One two-letter state code. |
statescsv | Up to 10 state codes. Overrides state. |
countystring | One county name, matched exactly. |
countiescsv | Up to 50 county names. Overrides county. |
municipalitystring | One municipality name. Null on much of the book — prefer county. |
municipalitiescsv | Up to 50 municipality names. Overrides municipality. |
Property and terms
Enumerated. Anything outside the listed values is a 400.
| Parameter | Meaning |
|---|---|
lien_typeenum | Certificate or redeemable deed. The two accrue and mature on different rules.lienredeemable_deed |
lien_typescsv | Comma-separated lien types. Overrides lien_type.lienredeemable_deed |
property_typeenum | Use of the underlying property.residentialindustrialvacant_landvacant_commercialcommercialagriculturalresidential_homesteadother |
property_typescsv | Comma-separated property types. Overrides property_type.residentialindustrialvacant_landvacant_commercialcommercialagriculturalresidential_homesteadother |
property_quality_gradescsv | Our own condition grade for the property, A (best) through D.ABCD |
deal_typeenum | How the asking price was set against redemptive value: at par, at a discount, at a premium, or fixed by hand.fixedpardiscountpremium |
deal_typescsv | Comma-separated deal types. Overrides deal_type.fixedpardiscountpremium |
acquisition_sourcescsv | Where the certificate came from — a county auction, or the secondary market.secondaryauction |
Status and maturity
Leave status alone to get what is for sale.
| Parameter | Meaning |
|---|---|
statusenum · default listed | Lifecycle status. listed also matches relisted, so the default returns everything currently on offer.listedrelistedactivepurchasedredeemedcancelledforeclosure_eligibleforeclosure_initiatedforeclosedexpired |
statusescsv | Comma-separated statuses. Overrides status.listedrelistedactivepurchasedredeemedcancelledforeclosure_eligibleforeclosure_initiatedforeclosedexpired |
maturity_afterdate | Maturity on or after this date, YYYY-MM-DD. Filters expiration_date. |
maturity_beforedate | Maturity on or before this date, YYYY-MM-DD. |
| Parameter | Meaning |
|---|---|
searchstring | Free text over street address, parcel ID, certificate number and county. 200 characters. |
sort_byenum · default newest_first | Sort order — the keys are in the next table. |
Statuses the marketplace does not publish
pending_review, available, rejected are the pre-approval pipeline and are refused on this endpoint with a 400, on status and statuses alike — including when one rides along inside an otherwise valid comma-separated list. created_by_admin_id is refused the same way.
statuses=listed is not the same query as status=listed
The singular form expands: status=listed matches listed and relisted together. The plural is taken verbatim, so statuses=listed silently drops every relisted lien and returns fewer rows than the default did. Send statuses=listed,relisted if what you want is everything on offer.
Expired certificates are filtered out regardless of status
A lien whose expiration_date has passed is excluded from this endpoint unless it is a redeemable_deed. So the terminal statuses are queryable but thin — ?status=expired returns nothing — and this is not the endpoint to reconstruct a historical track record from. Use /market/activity for what has actually filled.
Sort keys
Passed as sort_by. The default is newest_first.
| sort_by | Order |
|---|---|
newest_first | Most recently added first, by record creation time. The default. |
price_low_high | Cheapest asking price first. |
price_high_low | Most expensive asking price first. |
listing_price_low_high | Alias of price_low_high, kept so existing bookmarks do not 400. |
listing_price_high_low | Alias of price_high_low. |
discount_low_high | Smallest discount to redemptive value first. |
discount_high_low | Largest discount to redemptive value first. |
apy_low_high | Lowest net per-year rate first. |
apy_high_low | Highest net per-year rate first. This ranks on the same figure the marketplace prints in its "Per year" column — net of our fee, annualized simply — and NOT on calculated.apy. Liens under 30 days from maturity have no per-year rate and are parked last rather than dropped. |
maturity_soonest | Nearest maturity first. |
maturity_latest | Furthest maturity first. |
Response shape
What the versioned tree wraps everything in, and what the curated tree does instead.
{
"success": true,
"data": [ /* the liens */ ],
"meta": {
"pagination": { "total": 257, "page": 1, "limit": 20, "totalPages": 13,
"hasNextPage": true, "hasPrevPage": false,
"nextPage": 2, "prevPage": null },
"fee_config": { "interest_fee_bps": 1000, "fee_version": 1 },
"filters": { "status": "listed", "price_range": {}, "interest_range": {} },
"sorting": { "sort_by": "apy_high_low" }
}
}Read the fee from meta.fee_config rather than assuming 1000 bps — GET /liens/{id} carries the same object on the lien itself. It is read from the chain when a lien redeems, so it can change.
The /api/public tree does not use the envelope at all: it answers the object directly, or { error, message } with a 404 / 500.
Fields get added to these responses, so ignore what you do not recognize rather than failing on it.
Errors
Two layers refuse, and they do not refuse in the same shape.
A 400 comes back in one of two shapes, and it is worth handling both. Anything the route's own schema catches — a limit over 100, a non-numeric bound, an unknown status, a malformed id — is a framework validation error, and carries no success key at all:
{ "statusCode": 400, "code": "FST_ERR_VALIDATION", "error": "Bad Request",
"message": "querystring/limit must be <= 100" }The cross-field and policy checks — a minimum above its maximum, a refused status, created_by_admin_id — use the envelope:
{ "success": false, "message": "min_price must not exceed max_price" }So branch on the status code, not on body.success.
Connecting over MCP
A hosted Model Context Protocol server on the same base URL. Research needs no credential; wallet-scoped tools take a bearer authorization. Nothing to install. New here? The walkthrough below goes from the first call to a settled purchase, one step at a time.
POST https://api.lienfi.com/api/v1/mcpJSON-RPC 2.0 over stateless streamable HTTP. There is no session to open and no session id to carry: every request is independent, so a client can call tools/call without having called initialize first. Only POST is served — every other verb answers 405 in JSON-RPC shape rather than falling through to the REST envelope.
Methods
| Method | What it does |
|---|---|
initialize | Server name, version and the instructions string. Optional here — the transport is stateless, so nothing is negotiated and no session id comes back. |
tools/list | The live tool set with JSON Schema for each. Trust this over any documentation, including this page. |
tools/call | Run one tool. params: { name, arguments }. The result is a text content block carrying JSON. |
ping | Liveness. Answers an empty result, and needs no prior initialize. |
notifications/initialized | Accepted and acknowledged so a spec-conformant client’s handshake completes. It is a notification, so there is nothing to read from it. |
Adding the server
claude mcp add --transport http lienfi https://api.lienfi.com/api/v1/mcpAny client that reads an mcpServers block — Claude Desktop, Cursor, a repo-local .mcp.json — takes the same thing as configuration:
{
"mcpServers": {
"lienfi": {
"type": "http",
"url": "https://api.lienfi.com/api/v1/mcp"
}
}
}Or call it directly. The response is a JSON-RPC result whose content is a text block carrying JSON:
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Trust tools/list over this page
The tool set is advertised live and is the authority on what exists. The descriptions here are maintained by hand — they cannot be imported into this app from the API — so if the two ever disagree, the wire is right.
When something goes wrong
A malformed request is a JSON-RPC error object — no HTTP envelope, no success key:
{ "jsonrpc": "2.0", "id": 1,
"error": { "code": -32600, "message": "..." } }A tool that ran and refused is not an error at that layer. It answers a normal result with isError: true and a stable code inside the text block, so a program can branch on the reason rather than pattern-matching a sentence:
{ "content": [ { "type": "text",
"text": "{\"error\":{\"code\":\"lien_not_found\",\"message\":\"...\"}}" } ],
"isError": true }Every code, with what to do about it, is under Refusal codes.
Rate limit. 120 requests per minute per IP, which is generous enough that an agent listing tools and screening the book will not notice it. Over the budget you get a JSON-RPC error telling you to slow down, not an HTML page.
LienFi signs the price, never the transaction
The wallet-scoped tools are live. They use the bearer issued at the authorization page, never the agent’s private key. quote_lien returns an indicative price but mints no purchase signature. Buying over MCP is the purchase tools — prepare_purchase, confirm_purchase, report_purchase — which hand back UNSIGNED transactions for the agent’s own wallet to submit. All four are registered in production; tools/list is still the authority on any deployment. Nothing here submits a transaction or holds a key.
Walkthrough: first call to settled purchase
From the first call to a settled purchase, in order. Steps marked operator, in a browser happen in a browser, by a person; steps marked agent wallet are a signature or a transaction from the agent’s own wallet. Everything else is one tools/call.
See what this deployment serves
agentEvery request is one JSON-RPC 2.0 body to the endpoint above — there is no session and no handshake to complete first.
tools/listis the authority: in production all ten tools are registered; on another deployment the wallet and purchase halves may be off, and the list says so.request{"jsonrpc":"2.0","id":1,"method":"tools/list"}Returns
result.tools[], each with a JSON Schema for its arguments.No credential.
market_overviewsays what is on the book and where;search_liensscreens it and ranks the shortlist on NET yield, after our share of the gain — the same arithmetic this site displays;get_lienis the full record for one candidate. Liens under a month from maturity come back inmaturing_soonwith no per-year rate, and liens that cannot be scored are counted underdroppedrather than hidden.tools/call{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "search_liens", "arguments": { "budget_usd": 5000, "states": "FL", "limit": 5 } } }Returns
liensranked onnet_apy, plusmaturing_soon,dropped,fee_configandapy_labels. Keep thelien_idof the one you want.Your operator authorizes you, once, in a browser
operator, in a browserThe one human step. At
/agents/authorizeyour operator signs an authorization naming your wallet address, the most you may spend on one lien, a cumulative total (recorded and reported back to you, not enforced) and an expiry no more than 90 days out. The page then hands them one ready-made prompt to paste to you, carrying everything else you need: the key-proof typed data for YOUR wallet to sign, oneconsent-v2typed data per required agreement, the ready registration call, and anAuthorization: Bearer <blob>header that IS the authorization. The same envelopes, and anmcpServersblock, are on the page individually for a client that is driven by hand. When a tool answersnot_registered, check that the call carried that header before anything else — registering opens no session, and a call without it is refused this way even after you have registered. If it did, the refusal carrieshandoff_url: surface that link to your operator and stop; retrying cannot complete a browser step.Sign the key proof and the consents
agent walletFrom the agent wallet,
eth_signTypedData_v4over the key-proof typed data — it binds your key to that exact authorization — and over each consent typed data, one per agreement. Sign them exactly as printed: the API rebuilds every message and refuses a signature made for a different network. A smart-account wallet is fine; contract signatures are verified on chain at registration.register_agentisPOST /agents/registerwithout leaving MCP: the operator authorization comes from the bearer already on the request, so you pass only what your wallet signed.POST /agents/registertakes the same body if you would rather register over REST. From now on send the bearer on every call. Registering again with the same authorization is idempotent, and it tops up any consent that lapsed because a document was republished.tools/call · with Authorization: Bearer <blob>{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "register_agent", "arguments": { "agent_key_proof": { "specVersion": "agent-keyproof-v1", "signature": "0x…", "timestamp": 1756800000 }, "agent_consents": [ { "specVersion": "consent-v2", "signature": "0x…", "documentType": "terms-and-conditions", "version": 3, "timestamp": 1756800000 } ] } } }Returns
registered: true, the binding’sexpiresAt, andreplay: truewhen this was an idempotent repeat.The first call to make when anything refuses. It answers whether the binding is active and when it expires, the wallet’s USDC and ETH, and the spend record — what has settled and what is live, beside the caps your operator signed, with
enforced: falseon the cumulative because only the per-lien cap is refused on.tools/call · with Authorization: Bearer <blob>{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "agent_status", "arguments": {} } }Returns
registered,expires_at,balances,spend.An indicative price for one lien and whether the wallet can cover it. Nothing is signed and nothing is reserved, so ask as often as you like.
affordableis null when the balance could not be read — never a false no.tools/call · with Authorization: Bearer <blob>{ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "quote_lien", "arguments": { "lien_id": "<lien-uuid>" } } }Returns
lien_price_usdc,total_to_approve_usdc,affordable,shortfall_usdc.Step 1 of the loop. Runs every gate a purchase would refuse on — authorization, the signed per-lien cap, both parties’ consents, sanctions, your
max_total_usdcceiling, and the wallet’s balance — and only then RESERVES: one purchase in flight per wallet, for ten minutes, and returns the acknowledgment typed data. A refusal here leaves nothing behind.max_total_usdcis a stale-quote guard, not a budget; the budgets are the caps your operator signed.tools/call · with Authorization: Bearer <blob>{ "jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": { "name": "prepare_purchase", "arguments": { "lien_id": "<lien-uuid>", "max_total_usdc": 2000 } } }Returns
reservation_id,acknowledgment.typed_data,total_to_approve_usdc,expires_at,spend.Sign the acknowledgment
agent walleteth_signTypedData_v4overacknowledgment.typed_data, from the agent wallet, exactly as returned.confirm_purchaserebuilds the message from what prepare stored, so an edited or re-timestamped copy does not verify.Step 2. Records the acknowledgment as a pending legal record, THEN mints the LienFi price signature — bound to your wallet, valid for 300 seconds from this moment — and returns the transactions UNSIGNED:
approvefor exactly the total,buyNFTcarrying the signature, andsetApprovalForAllfor the vault unless it is already granted. If the live price moved above your ceiling, the reservation is released and the signature discarded (quote_above_ceiling). Calling it again on the same live reservation returns a fresh bundle without re-recording the acknowledgment.tools/call · with Authorization: Bearer <blob>{ "jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": { "name": "confirm_purchase", "arguments": { "reservation_id": "<reservation-uuid>", "acknowledgment_signature": "0x…" } } }Returns
transactions[]as{ step, name, to, data, value, chainId, why },data_suffix,expires_at,consent_id.Submit the transactions, in order, from the agent wallet
agent walletEach one after the previous is mined with
status: success. A reverted transaction is mined too, and abuyNFTsent after a revertedapprovefails on allowance, one step removed from the cause. Appenddata_suffixto the calldata you actually broadcast — for a smart account that is the outerexecutecall — it is Base Builder Code attribution, and omitting it only loses attribution. All of it inside the 300 seconds. WithoutsetApprovalForAllthe lien is owned but cannot be redeemed.Step 3. Pass the
buyNFThash and LienFi reads the receipt: a success carrying the purchase event settles the reservation; a revert releases it and marks the acknowledgment failed; an unmined hash answersreceipt_pending— retry once it confirms. If you never submitted, reportfailed: trueand the wallet may prepare another purchase at once. The indexer settles the record on its own too; reporting makes it immediate.tools/call · with Authorization: Bearer <blob>{ "jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": { "name": "report_purchase", "arguments": { "reservation_id": "<reservation-uuid>", "tx_hash": "0x…" } } }Returns
settled: true,paid_usdc,vault_approval— and awarningwhile the vault approval is not confirmed.What the wallet holds, valued live and confirmed on chain, with anything the chain does not confirm reported separately rather than hidden. A purchase completed a moment ago may take the indexer a little while to appear.
tools/call · with Authorization: Bearer <blob>{ "jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": { "name": "my_positions", "arguments": {} } }Returns
confirmed_on_chain,claimed_but_not_confirmed,unknown_on_chain,vault_approval.
The rules the loop depends on
- One purchase in flight per wallet. ERC-20
approveSETS the allowance, so two live quotes would let one overwrite the other’s.purchase_in_flightnames the live one; finish or report it. - 300 seconds. The price signature is bound to your wallet and expires; the clock starts at
confirm_purchase, which is why the acknowledgment is signed before it, not after. - Approve exactly the total, never unlimited. It is one of the two structural bounds on a confused agent; the other is the wallet balance.
- Purchases are final. A lien the agent buys belongs to its operator; there is no undo.
- The purchase tools go one message per request. Inside a JSON-RPC batch they answer
batch_not_allowed, and each binding gets 30 purchase-tool calls a minute. - LienFi signs the price and nothing else. No key is held here and nothing is submitted on your behalf; every transaction leaves your wallet.
Without MCP
The same loop over REST, with the same gates and the same rules. Build the three transactions yourself from the quote; the argument order is the marketplace contract’s, and maturity is the quote’s, never a recomputed one.
# 1. every gate, no signature, no reservation — needs no header
GET https://api.lienfi.com/api/v1/liens/{id}/buy-price?buyer=<agent>&intent=preflight
# 2. the signed quote. For a wallet bound to an agent authorization this IS the
# reservation, and it takes the same bearer the MCP endpoint takes:
GET https://api.lienfi.com/api/v1/liens/{id}/buy-price?buyer=<agent>
Authorization: Bearer <blob>
# 3. from the agent wallet, in order, each after the previous is mined:
# USDC.approve(marketplace, totalAmount)
# LienMarketplace.buyNFT(tokenId, lienPrice, maturity, deadline, baseValue, feeVersion, signature)
# LienNFT.setApprovalForAll(vault, true) # once per wallet; without it the lien cannot be redeemedMCP tools
Ten tools in three groups, all registered in production. The last seven require the bearer authorization issued at /agents/authorize; on another deployment tools/list says which halves are on.
Research — no credential
These work with no header at all. Start here.
Inventory shape: how many liens are listed, which states and counties they are in, and the price / rate / LTV bounds.
Cheap, and the right first call. It is the same data as GET /liens/facets, and it turns "no results" from an ambiguous answer into a specific one.
Arguments
None. It takes an empty object.
Returns
totalHow many liens are on the live book at all.states, counties, municipalitiesThe distinct values that actually have live inventory, each with a count.countiesandmunicipalitiescarry their parent so you can narrow without a second call.propertyTypes, qualityGradesThe distinct values on the live book, not the whole enum — and camelCase, unlike the REST tree’s snake_case query params.dealTypesHow the live listings are priced, each with a count:partracks the redemptive value and rises with accrual,fixedis a flat dollar amount that does not, anddiscount/premiumare a set percentage either side of the redemptive value, recalculated on every quote. Filter on it throughGET /liens?deal_types=.rangesAn OBJECT, not top-level fields:listing_price,interest_rate,face_value,assessed_valueandltv_ratio, each{ min, max }or null. Read a bound asranges.listing_price.max.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"market_overview","arguments":{}}}'Screen listed liens and return a shortlist ranked by NET yield — after our share of the gain — rather than by the gross APY the REST API publishes.
This is the tool worth using over GET /liens. It ranks on the same arithmetic this site displays, so you get our numbers rather than your reconstruction of them.
Scan wide, return narrow: it always screens a 100-row page and limit controls only the response size, because ranking on net yield is only meaningful over a decent sample.
Arguments
budget_usdnumber · optionalUpper bound on price. It filters the STORED listing_price column while a real quote recomputes the price from live redemptive value, so a lien inside this budget can still quote above it.min_interestnumber · optionalMinimum statutory interest rate, percent.max_ltvnumber · optionalMaximum loan-to-value, percent.statesstring · optionalComma-separated two-letter codes, e.g. "FL,TX".countiesstring · optionalComma-separated county names.property_typesstring · optionalComma-separated, e.g. "residential,vacant_land".limitnumber · optionalShortlist size, defaulting to 10. Out-of-range values are CLAMPED to 1–25 rather than refused, solimit: 1000quietly returns 25. Applied to the ranked list and to maturing_soon SEPARATELY, so a full response can carry twice this many rows. The page scanned is always 100.
Returns
liensThe shortlist, ranked onnet_apydescending.maturing_soonLiens under 30 days from maturity, in their own list, ranked onnet_return_pct. They carry no per-year rate at all.scanned, ranked_returned, ranked_total, maturing_soon_returned, maturing_soon_totalFive counts — the page size scanned, plus returned and total for each of the two lists, which are capped independently. A truncated list otherwise reads as "these are all of them".droppedLiens that could not be scored, counted rather than silently omitted. It also holds anything maturing within 24 hours, underno_remaining_term.fee_config, noteThe live fee rate and the caveat that has to travel with any net figure.apy_labelsWhat each rate on a row means, in the response itself, so a client never has to guess.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search_liens","arguments":{"budget_usd": 5000, "min_interest": 12}}}'The full record for one lien, including the redemptive-value projection and the fee rate that applies to it.
Use after search_liens to inspect a candidate.
Arguments
lien_idstring · requiredThe lien UUID.
Returns
the whole lien rowEvery columnGET /liens/{id}returns — around 60 of them, includingtransactionsand the owner addresses. Only the projections are compacted; there is no field allowlist on this tool, unlike the rowssearch_liensreturns.calculated.redemptive_value_projection_summaryAnd..._from_start_summary. Note the_summarysuffix: the month-by-month ARRAYS are removed and these objects (from/to/change/points) take their place under a different key, deliberately, so code expecting an array cannot read.lengthoff an object and treat a full projection as empty.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"get_lien","arguments":{"lien_id": "<lien-uuid>"}}}'Wallet — bearer authorization
Read-only, about one authorized wallet. Send the Authorization: Bearer <blob> the authorization page printed; nothing here signs or reserves.
Whether the authorized agent wallet is registered, when its authorization expires, and its current USDC and ETH balances.
Requires Authorization: Bearer <authorization>, using the blob issued at /agents/authorize. Call this first when another wallet-scoped tool refuses.
Arguments
None. It takes an empty object.
Returns
agent_wallet, chain_idThe wallet and deployment chain the bearer authorization is bound to.registered, binding_existsWhether the binding is active now, and whether one exists at all. An expired or revoked binding still exists but is not registered.expires_at, revoked_at, days_until_expiry, expiry_warningThe binding lifecycle, including a warning during the final 14 days of an active authorization.balancesCurrent USDC and native ETH balances. A chain-read failure is returned as an error inside this field without hiding the registration state.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <authorization>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"agent_status","arguments":{}}}'An indicative price for one lien, scoped to the authorized agent wallet, plus whether that wallet can afford it.
Requires the bearer authorization. No purchase signature is minted and nothing is approved or bought; the price is recomputed on the eventual purchase path.
Arguments
lien_idstring · requiredThe lien UUID.
Returns
lien_id, agent_wallet, token_idThe quote subjects.lien_price_usdcThe amount the marketplace contract transfers for the lien.total_to_approve_usdcThe USDC approval ceiling, including any listing fee even though the contract does not transfer that fee.indicative, affordable, shortfall_usdc, noteWhether this is non-binding, the measured affordability result, any shortfall, and the next-step caveat.affordableis null when the balance read fails, never falsely reported as no.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <authorization>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"quote_lien","arguments":{"lien_id": "<lien-uuid>"}}}'Liens attributed to the authorized agent wallet, valued live and separated by whether current onchain ownership confirms them.
Requires the bearer authorization. Values are recomputed live; a just-completed purchase may be absent until the indexer records it.
Arguments
None. It takes an empty object.
Returns
agent_wallet, scannedThe wallet queried and number of indexed rows examined.confirmed_on_chainPositions whose NFT ownership the chain confirms for this agent wallet.claimed_but_not_confirmedRows attributed to the wallet in the database whose NFT has a different owner.unknown_on_chain, chain_reads_failedRows whose ownership could not be checked, kept separate from real disagreements, plus the failure count.coverage, truncatedHow the chain check covered the result and, when present, notice that the position list hit its safety cap.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <authorization>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"my_positions","arguments":{}}}'Buying — bearer authorization
The loop, in order, plus registration. Registered in production; LienFi signs the price and never a transaction, and every transaction leaves the agent’s own wallet.
Step 1 of 3. Checks everything a purchase would refuse on — authorization, the per-lien cap, consents, sanctions, your ceiling, the wallet’s balance — then RESERVES the purchase and returns the acknowledgment typed data for the agent wallet to sign. LienFi signs nothing here.
Requires the bearer authorization. One purchase in flight per wallet: if another lien already holds a live signed quote the refusal is purchase_in_flight, naming its reservation_id, lien_id and quote_deadline — finish or report that one, do not retry around it.
Calling it again for the same lien is safe: it returns the same reservation_id and, while at least five of the ten minutes remain, the same typed_data, so a retry never invalidates a signature you are about to send.
Refusal codes that pass straight through from the price service: authorization_revoked, authorization_expired, authorization_missing_caps, cap_exceeded_per_purchase, consent_required, agent_consent_required, sanctioned_address, sanctions_screening_unavailable. This tool’s own: quote_above_ceiling, insufficient_funds (with shortfall_usdc), affordability_unavailable (the balance could not be read — never reported as affordable), spend_ledger_unavailable.
Arguments
lien_idstring · requiredThe lien UUID.max_total_usdcnumber · requiredRefuse if the quote totals more than this, in USDC. A stale-quote and wrong-lien guard, NOT a budget — the budgets are the two caps the operator signed.
Returns
reservation_id, lien_id, token_id, agent_walletThe reservation to pass toconfirm_purchase, and what it is for.lien_price_usdc, total_to_approve_usdc, ceiling_usdcThe indicative price, the total the approval must cover (price plus any listing fee), and the ceiling you passed. The price is re-quoted at confirm and may move within the ceiling.spendsettled_usdc,in_flight_usdc,max_total_usdcandenforced: false: what this authorization has spent and has live, beside the cumulative the operator signed. Reported, never refused on — the signed per-lien cap is the one that is enforced.expires_atWhen this reservation lapses if never confirmed (ten minutes). Nothing is bought until the transactionsconfirm_purchasereturns are submitted.acknowledgmenttyped_data(EIP-712,agent-purchase-consent-v1, actionlog),sign_with: eth_signTypedData_v4,spec_version,then: confirm_purchase, andreused— true when this is the same typed data an earlier call for this lien returned, so a signature you already made over it still verifies. Sign it from the agent wallet and pass the signature on.noteThe next step, in one sentence. Purchases are final.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <authorization>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"prepare_purchase","arguments":{"lien_id": "<lien-uuid>", "max_total_usdc": 2000}}}'Step 2 of 3. Records the signed acknowledgment, mints the LienFi price signature (valid 300 seconds) and returns UNSIGNED transactions for the agent wallet to submit IN ORDER, each after the previous one is mined with status success. LienFi signs nothing else and submits nothing.
Requires the bearer authorization. Calling it again on the same live reservation returns a fresh bundle without re-recording the acknowledgment. If the live price has moved above the ceiling from prepare_purchase, the reservation is released, the minted signature is discarded and the refusal is quote_above_ceiling.
Refusals: reservation_not_found (one answer for every miss), reservation_expired, reservation_settled, acknowledgment_invalid, plus everything the price service refuses on.
Arguments
reservation_idstring · requiredFromprepare_purchase.acknowledgment_signaturestring · requiredThe agent wallet’seth_signTypedData_v4signature over the typed dataprepare_purchasereturned.
Returns
reservation_id, lien_id, token_id, agent_wallet, lien_price_usdc, total_to_approve_usdcThe reservation, now quoted, and the live price the bundle was built from.transactionsTwo or three objects, each{ step, name, to, data, value, chainId, why }:approve(USDC, for EXACTLY the total — never unlimited),buyNFT(the marketplace; carries the price signature, bound to this wallet and expiring), andsetApprovalForAll(LienNFT,optional: true,idempotent: true, present unless the vault is already approved — WITHOUT it the lien is owned but cannot be redeemed).data_suffix, data_suffix_noteThe Base Builder Code (ERC-8021) attribution suffix, returned BESIDE the bundle for the submitter to append to whatever it actually broadcasts — for a smart account that is the outer execute call, not the innerdata. Omitting it only loses attribution.submit, quote_deadline, expires_at, thenThe order rule (a reverted transaction is mined too), when the price signature stops being accepted, and the next tool.consent_id, acknowledgment, vault_approval, spendThe acknowledgment row (recorded as pending; the indexer confirms it when the purchase is indexed), the vault approval read once for this wallet (approved/unapproved/unknown), and the spend record beside the signed total, in the same shapeprepare_purchasereports it.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <authorization>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"confirm_purchase","arguments":{"reservation_id": "<reservation-uuid>", "acknowledgment_signature": "0x…"}}}'Step 3 of 3. Report the buyNFT transaction hash and LienFi reads the receipt — a success settles the reservation, a revert releases it and marks the acknowledgment failed, an unmined one is receipt_pending (retry). Or report failed: true to release a purchase you never submitted so the wallet can buy again.
Requires the bearer authorization. Settlement is monotonic: a reservation the chain has already settled refuses reservation_settled whatever is reported, so a late "failed" can never undo it. The indexer settles the record on its own too; reporting makes it immediate.
Refusals: reservation_not_found, reservation_settled, reservation_not_quoted (a hash on a reservation that never received a signed quote), receipt_pending, receipt_unavailable (the chain could not be read — the transaction MAY still be in flight), receipt_mismatch.
Arguments
reservation_idstring · requiredFromprepare_purchase.tx_hashstring · optionalThe buyNFT transaction hash (32 bytes). Not the approve — that hash succeeds but carries no purchase, and answersreceipt_mismatch.failedboolean · optionalTrue if the purchase was abandoned or rejected before submission.reasonstring · optionalWhy, whenfailedis true. Up to 500 characters; recorded on the acknowledgment.
Returns
reservation_id, settled, lien_id, token_id, tx_hash, block_number, paid_usdc, ledger, vault_approval, acknowledgmentOn a successful buyNFT receipt: the settlement, the price the chain event carried, the ledger outcome, and whether the vault approval is confirmed. Awarningrides along when it is not — the lien is owned but CANNOT BE REDEEMED untilsetApprovalForAllsucceeds.released, reverted, previous_state, noteOn a revert or afailed: truereport: the reservation is released so the wallet may prepare another purchase, andacknowledgmentsays whether the pending acknowledgment was marked failed (only when THIS reservation created it).
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <authorization>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"report_purchase","arguments":{"reservation_id": "<reservation-uuid>", "tx_hash": "0x…"}}}'Bind the agent wallet to the operator authorization carried in the Authorization header — POST /agents/register without leaving MCP. Pass the key proof and the consent-v2 acceptances the agent wallet signed, exactly as the authorization page lays them out.
Works with the bearer in the not_registered state (the point) and as a replay for a bound wallet, which tops up any lapsed consent. The operator authorization itself is never an argument: it is the bearer. A service refusal comes back as registration_refused with the HTTP status the REST route would have answered.
Arguments
agent_key_proofobject · requiredTheagent-keyproof-v1signature over the authorization digest, from the agent wallet.agent_consentsarray · requiredOne{ specVersion, signature, documentType, version, timestamp }per required document, signed from the agent wallet over the version the API publishes.labelstring · optionalOptional operator-facing name, up to 120 characters.
Returns
registered, replayWhether the binding now exists, and whether this call was an idempotent replay of an existing one.agentWallet, operatorUserId, consentId, expiresAt, alreadyRegisteredThe registration record, exactly asPOST /agents/registerreturns it.noteKeep sending the same bearer; the wallet-scoped tools work from now on.
curl -s -X POST 'https://api.lienfi.com/api/v1/mcp' \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <authorization>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"register_agent","arguments":{"agent_key_proof": {"specVersion":"agent-keyproof-v1","signature":"0x…","timestamp":1756800000}, "agent_consents": [{"specVersion":"consent-v2","signature":"0x…","documentType":"terms-and-conditions","version":3,"timestamp":1756800000}]}}}'Why search_liens beats GET /liens for screening
It ranks on net yield using the same arithmetic this site displays, so the shortlist, the lien page and the marketplace column cannot report different money for one lien. Liens under a month from maturity come back in a separate maturing_soon list carrying no per-year rate — return × 365 / days is unbounded as the term falls, so annualizing one would put a trivial gain at the top of the ranking. Liens that cannot be scored at all are counted rather than silently dropped, and the projections are summarised to first/last/change so a single page does not fill your context.
Refusal codes
Every error.code a tool can answer, and what to do about it. A refusal is a normal result with isError: true; the code sits inside the text block beside a sentence written for the model, so a program branches on the code and the model reads the sentence.
| Code | What it means | What to do |
|---|---|---|
invalid_arguments | The arguments failed validation. | Fix the call against the schema in tools/list. Never retry it unchanged. |
not_registered | No Authorization header reached the call, or no registration exists for the wallet the bearer names; the message says which. Carries handoff_url. | Check the header first: if you already hold the blob, send it as Authorization: Bearer <blob> on every call — registering opens no session. Only then surface handoff_url to your operator and stop. |
authorization_malformed | The Authorization header could not be read as an authorization blob. | Send the blob exactly as the authorization page printed it — base64url or raw JSON. |
authorization_mismatch | This wallet is registered under a different signature than the one presented. | Use the blob it was registered with. For a NEW authorization, the operator revokes the active one first; then register. |
authorization_revoked | The operator revoked this authorization. | Stop. A new authorization is required. |
authorization_expired | The authorization’s term ran out. | Stop. Ask the operator to re-authorize. |
authorization_unavailable | The credential could not be CHECKED — an outage on our side, not a bad credential. | Retry shortly. Do not re-authorize. |
authorization_required | REST only: a signed quote for a bound wallet was asked for without its operator authorization, or with a different one. | Send Authorization: Bearer <blob>, or ask with intent=indicative or intent=preflight, which need none. |
authorization_missing_caps | The authorization carries no spend cap. | Ask the operator to re-authorize; a cap is part of what they sign. |
binding_mismatch | The wallet was re-registered under a newer authorization since this request started. | Retry with the current bearer. |
lien_not_found | No lien has that id. | Check the id; search_liens returns real ones. |
lien_not_purchasable | The lien exists but cannot be quoted or bought — delisted, lapsed or matured. The message says which. | Pick another lien. |
cap_exceeded_per_purchase | The lien costs more than the per-lien cap the operator signed. | Pick a cheaper lien. The cap is the operator’s to raise, by a new authorization. |
consent_required | The OPERATOR has not accepted the current LienFi agreements. | Surface it to the operator; that acceptance happens in a browser. |
agent_consent_required | The agent wallet’s own acceptance is missing or stale — usually a republished document. | Re-sign the consents from the agent wallet and register again; a replay tops them up. |
consent_unavailable | A required agreement is unpublished on our side. | Retry later. Not your fault. |
sanctioned_address | The wallet is on a sanctions list. | Stop. Terminal. |
sanctions_screening_unavailable | The sanctions oracle could not be read; the screen fails closed rather than passing. | Retry shortly. |
purchase_in_flight | This wallet already holds a live signed quote for another lien — named by reservation_id, lien_id and quote_deadline. | Finish and report that purchase, or report it failed. Do not retry around it; a second live quote would let one approve overwrite the other. |
spend_ledger_unavailable | The reservation ledger could not be written, or is disabled on this deployment (the message says which). | Retry shortly. Where it is disabled, buy over REST from your own wallet. |
quote_above_ceiling | The quote is above the max_total_usdc you passed. Nothing was reserved (or the reservation was released, at confirm). | Raise the ceiling and prepare again, or skip the lien. |
insufficient_funds | The wallet cannot cover the total; shortfall_usdc says by how much. Nothing was reserved. | Fund the wallet and prepare again. |
affordability_unavailable | The balance could not be read. It is never assumed affordable. | Retry shortly. |
reservation_not_found | No reservation with that id belongs to this binding — one answer for every miss. | Use the reservation_id prepare returned to you. |
reservation_expired | The reservation lapsed, was released, or its window passed. | Start again with prepare_purchase. |
reservation_settled | The reservation already settled on chain. Nothing reported now can undo that. | Nothing to do; my_positions shows the lien. |
reservation_not_quoted | A transaction hash was reported for a reservation that never received a signed quote, so no purchase can exist for it. | Report it failed if you did not buy; otherwise call confirm_purchase first. |
acknowledgment_invalid | The acknowledgment signature did not verify for this wallet and lien. The reservation was released. | Prepare again and sign the typed data exactly as returned, from the agent wallet. |
receipt_pending | The transaction is not mined yet. | Retry report_purchase once it confirms. |
receipt_unavailable | The chain could not be read. The transaction MAY still be in flight. | Call my_positions before retrying anything. |
receipt_mismatch | The transaction mined but carries no purchase of this lien by this wallet — the approve hash, or someone else’s. | Report the buyNFT hash instead. |
batch_not_allowed | A purchase tool was called inside a JSON-RPC batch. | Send it as the only message in the request. |
registration_refused | POST /agents/register refused; the message carries its sentence and HTTP status. | Read the sentence. "This agent wallet already has a different active authorization": the operator revokes it first, then you register. "Another LienFi account holds the active authorization": the message names the wallet that signed yours; the operator either signs from the account that holds the binding, or signs in as that account, revokes there, and registers this one. |
rate_limited | This binding exceeded the purchase tools’ budget of 30 calls a minute. | Slow down and retry. |
internal | An unclassified fault on our side. The detail is in our logs, not in the answer. | Retry shortly; report it if it persists. |
Some carry more than a code: not_registered and the authorization_* refusals carry handoff_url for the operator; purchase_in_flight names the live reservation; insufficient_funds carries the shortfall; quote_above_ceiling carries the quoted total and your ceiling. The retryable ones say so in their sentence; nothing marked terminal changes on a retry.
Limits, CORS and caching
Read these before you write the client, not after the first CORS error.
CORS. The /api/public tree, /openapi.json and /mcp send Access-Control-Allow-Origin: * with credentials: false. The rest of /api/v1, including GET /liens, is on an exact-origin allowlist — so a browser fetch straight from your own domain will be blocked there. Call it server-side, or use the public tree.
Rate limits. The REST read routes are not rate limited per caller today; POST /mcp is, at 120 per minute per IP. Neither is a promise — it is a serverless deployment and platform limits still apply, so cache what you fetch and page rather than looping.
Caching. /api/public/liens/{id}, /liens/facets and /openapi.json send Cache-Control: public, max-age=300, and /market/activity sends max-age=30. Five minutes is short by the standards of data that moves monthly, so cache what you fetch rather than re-requesting it. Whether any given response also came from a CDN in front of us is not something the header will tell you, and not something to build on.
Versioning. Fields get added to these responses, so ignore what you do not recognize rather than failing on it. There is no published deprecation policy behind the v1 in the path — if you are building something you need to keep working, tell us and we will tell you before anything moves.
Machine-readable versions
The same material, for something that is not a person.
- OpenAPI 3.1 — generated from the routes' own schema blocks. The admin, internal and webhook trees are withheld from it deliberately.
- /llms.txt — the same conventions as prose for a model, and the entry point an agent should be given.
- /sitemap.xml — every live lien page, each of which server-renders a record of the listing.
Before you summarize a listing
A tax lien is not a deposit and not a bond. Redemption is the expected outcome but not a guaranteed one, and the remedy if it does not occur is foreclosure on the property — slow, jurisdiction-specific, and not something this platform underwrites. Rates and deadlines are set by state statute and differ by state. Read the risk disclosures in the Terms (sections 4 and 5) before presenting any of this as an investment recommendation, and how tax liens work for the statutory mechanics per state.