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.

brief
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, then GET /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_liens ranks 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.

versioned tree
https://api.lienfi.com/api/v1
curated public tree
https://api.lienfi.com/api/public

The 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.

curl
# 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.

install
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.

typescript
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.

example
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.

cached max-age 5 min
example
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

  • id path · 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.

example
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

  • id path · uuid · requiredThe lien id.

Response

A flat object, or { error, message } with a 404 / 500. No success key either way.

no envelopeCORS: *cached max-age 5 min
example
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.

example
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

  • id path · uuid · requiredThe lien id.
  • buyer query · address · requiredThe wallet that will submit the purchase, 0x-prefixed.
  • intent query · signed | indicative | preflightDefault signed. 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.

example
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

  • id path · uuid · requiredThe lien id.

Response

An image URL, or a null-ish payload for a property with no resolvable panorama.

example
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

  • id path · uuid · requiredThe lien id.

Response

An image URL.

example
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

  • limit query · integerEvents to return, 1–25. Defaults to 10.
  • include_summary query · booleanAdds lifetime sale totals to meta. Defaults to false.

Response

Recent fills with their prices and lots.

cached max-age 30 s
example
curl -s 'https://api.lienfi.com/api/v1/market/activity?limit=10&include_summary=true'

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

  • type query · enum · requiredThe document slug, e.g. terms-and-conditions.

Response

The document body, its version and its effective date.

example
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.

CORS: *cached max-age 5 min
example
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.

no envelopeCORS: *

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.

ParameterMeaning
pageinteger · default 1Page number, 1-based.
limitinteger · default 20Rows 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.

ParameterMeaning
min_pricenumberMinimum asking price, USD.
max_pricenumberMaximum asking price, USD.
min_face_valuenumberMinimum face value — the delinquent tax the certificate was struck for, not what it sells for.
max_face_valuenumberMaximum face value, USD.
min_assessed_valuenumberMinimum county-assessed value of the underlying property, USD.
max_assessed_valuenumberMaximum county-assessed value, USD.
min_interestnumberMinimum 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_interestnumberMaximum statutory interest rate, percent.
min_ltvnumberMinimum loan-to-value, percent. Accepts 0–999.99; that ceiling is the column width, not a business rule.
max_ltvnumberMaximum loan-to-value, percent.

Location

Call /liens/facets first — it returns the states and counties that actually have inventory, so you are not guessing.

ParameterMeaning
statestringOne two-letter state code.
statescsvUp to 10 state codes. Overrides state.
countystringOne county name, matched exactly.
countiescsvUp to 50 county names. Overrides county.
municipalitystringOne municipality name. Null on much of the book — prefer county.
municipalitiescsvUp to 50 municipality names. Overrides municipality.

Property and terms

Enumerated. Anything outside the listed values is a 400.

ParameterMeaning
lien_typeenumCertificate or redeemable deed. The two accrue and mature on different rules.
lienredeemable_deed
lien_typescsvComma-separated lien types. Overrides lien_type.
lienredeemable_deed
property_typeenumUse of the underlying property.
residentialindustrialvacant_landvacant_commercialcommercialagriculturalresidential_homesteadother
property_typescsvComma-separated property types. Overrides property_type.
residentialindustrialvacant_landvacant_commercialcommercialagriculturalresidential_homesteadother
property_quality_gradescsvOur own condition grade for the property, A (best) through D.
ABCD
deal_typeenumHow the asking price was set against redemptive value: at par, at a discount, at a premium, or fixed by hand.
fixedpardiscountpremium
deal_typescsvComma-separated deal types. Overrides deal_type.
fixedpardiscountpremium
acquisition_sourcescsvWhere the certificate came from — a county auction, or the secondary market.
secondaryauction

Status and maturity

Leave status alone to get what is for sale.

ParameterMeaning
statusenum · default listedLifecycle status. listed also matches relisted, so the default returns everything currently on offer.
listedrelistedactivepurchasedredeemedcancelledforeclosure_eligibleforeclosure_initiatedforeclosedexpired
statusescsvComma-separated statuses. Overrides status.
listedrelistedactivepurchasedredeemedcancelledforeclosure_eligibleforeclosure_initiatedforeclosedexpired
maturity_afterdateMaturity on or after this date, YYYY-MM-DD. Filters expiration_date.
maturity_beforedateMaturity on or before this date, YYYY-MM-DD.
ParameterMeaning
searchstringFree text over street address, parcel ID, certificate number and county. 200 characters.
sort_byenum · default newest_firstSort 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_byOrder
newest_firstMost recently added first, by record creation time. The default.
price_low_highCheapest asking price first.
price_high_lowMost expensive asking price first.
listing_price_low_highAlias of price_low_high, kept so existing bookmarks do not 400.
listing_price_high_lowAlias of price_high_low.
discount_low_highSmallest discount to redemptive value first.
discount_high_lowLargest discount to redemptive value first.
apy_low_highLowest net per-year rate first.
apy_high_lowHighest 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_soonestNearest maturity first.
maturity_latestFurthest maturity first.

Response shape

What the versioned tree wraps everything in, and what the curated tree does instead.

GET /liens
{
  "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:

schema-level 400
{ "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:

policy-level 400
{ "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.

endpoint
POST https://api.lienfi.com/api/v1/mcp

JSON-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

MethodWhat it does
initializeServer name, version and the instructions string. Optional here — the transport is stateless, so nothing is negotiated and no session id comes back.
tools/listThe live tool set with JSON Schema for each. Trust this over any documentation, including this page.
tools/callRun one tool. params: { name, arguments }. The result is a text content block carrying JSON.
pingLiveness. Answers an empty result, and needs no prior initialize.
notifications/initializedAccepted 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 code
claude mcp add --transport http lienfi https://api.lienfi.com/api/v1/mcp

Any client that reads an mcpServers block — Claude Desktop, Cursor, a repo-local .mcp.json — takes the same thing as configuration:

mcpServers
{
  "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:

tools/list
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:

protocol error
{ "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:

tool refusal
{ "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.

  1. See what this deployment serves

    agent

    Every request is one JSON-RPC 2.0 body to the endpoint above — there is no session and no handshake to complete first. tools/list is 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.

  2. Research: the book, then a shortlist, then one lien

    agentsearch_liens

    No credential. market_overview says what is on the book and where; search_liens screens it and ranks the shortlist on NET yield, after our share of the gain — the same arithmetic this site displays; get_lien is the full record for one candidate. Liens under a month from maturity come back in maturing_soon with no per-year rate, and liens that cannot be scored are counted under dropped rather 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 liens ranked on net_apy, plus maturing_soon, dropped, fee_config and apy_labels. Keep the lien_id of the one you want.

  3. Your operator authorizes you, once, in a browser

    operator, in a browser

    The one human step. At /agents/authorize your 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, one consent-v2 typed data per required agreement, the ready registration call, and an Authorization: Bearer <blob> header that IS the authorization. The same envelopes, and an mcpServers block, are on the page individually for a client that is driven by hand. When a tool answers not_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 carries handoff_url: surface that link to your operator and stop; retrying cannot complete a browser step.

  4. Sign the key proof and the consents

    agent wallet

    From the agent wallet, eth_signTypedData_v4 over 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.

  5. Register

    agentregister_agent

    register_agent is POST /agents/register without leaving MCP: the operator authorization comes from the bearer already on the request, so you pass only what your wallet signed. POST /agents/register takes 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’s expiresAt, and replay: true when this was an idempotent repeat.

  6. Check yourself

    agentagent_status

    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: false on 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.

  7. Price a candidate

    agentquote_lien

    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. affordable is 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.

  8. Prepare the purchase

    agentprepare_purchase

    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_usdc ceiling, 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_usdc is 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.

  9. Sign the acknowledgment

    agent wallet

    eth_signTypedData_v4 over acknowledgment.typed_data, from the agent wallet, exactly as returned. confirm_purchase rebuilds the message from what prepare stored, so an edited or re-timestamped copy does not verify.

  10. Confirm: the acknowledgment is recorded, then the price is signed

    agentconfirm_purchase

    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: approve for exactly the total, buyNFT carrying the signature, and setApprovalForAll for 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.

  11. Submit the transactions, in order, from the agent wallet

    agent wallet

    Each one after the previous is mined with status: success. A reverted transaction is mined too, and a buyNFT sent after a reverted approve fails on allowance, one step removed from the cause. Append data_suffix to the calldata you actually broadcast — for a smart account that is the outer execute call — it is Base Builder Code attribution, and omitting it only loses attribution. All of it inside the 300 seconds. Without setApprovalForAll the lien is owned but cannot be redeemed.

  12. Report the result

    agentreport_purchase

    Step 3. Pass the buyNFT hash 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 answers receipt_pending — retry once it confirms. If you never submitted, report failed: true and 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 a warning while the vault approval is not confirmed.

  13. Verify

    agentmy_positions

    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 approve SETS the allowance, so two live quotes would let one overwrite the other’s. purchase_in_flight names 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.

REST
# 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 redeemed

MCP 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. counties and municipalities carry 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: par tracks the redemptive value and rises with accrual, fixed is a flat dollar amount that does not, and discount / premium are a set percentage either side of the redemptive value, recalculated on every quote. Filter on it through GET /liens?deal_types=.
  • rangesAn OBJECT, not top-level fields: listing_price, interest_rate, face_value, assessed_value and ltv_ratio, each { min, max } or null. Read a bound as ranges.listing_price.max.
tools/call
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_usd number · 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_interest number · optionalMinimum statutory interest rate, percent.
  • max_ltv number · optionalMaximum loan-to-value, percent.
  • states string · optionalComma-separated two-letter codes, e.g. "FL,TX".
  • counties string · optionalComma-separated county names.
  • property_types string · optionalComma-separated, e.g. "residential,vacant_land".
  • limit number · optionalShortlist size, defaulting to 10. Out-of-range values are CLAMPED to 1–25 rather than refused, so limit: 1000 quietly 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 on net_apy descending.
  • maturing_soonLiens under 30 days from maturity, in their own list, ranked on net_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, under no_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.
tools/call
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_id string · requiredThe lien UUID.

Returns

  • the whole lien rowEvery column GET /liens/{id} returns — around 60 of them, including transactions and the owner addresses. Only the projections are compacted; there is no field allowlist on this tool, unlike the rows search_liens returns.
  • calculated.redemptive_value_projection_summaryAnd ..._from_start_summary. Note the _summary suffix: 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 .length off an object and treat a full projection as empty.
tools/call
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.
tools/call
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_id string · 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. affordable is null when the balance read fails, never falsely reported as no.
tools/call
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.
tools/call
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_id string · requiredThe lien UUID.
  • max_total_usdc number · 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 to confirm_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_usdc and enforced: 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 transactions confirm_purchase returns are submitted.
  • acknowledgmenttyped_data (EIP-712, agent-purchase-consent-v1, action log), sign_with: eth_signTypedData_v4, spec_version, then: confirm_purchase, and reused — 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.
tools/call
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_id string · requiredFrom prepare_purchase.
  • acknowledgment_signature string · requiredThe agent wallet’s eth_signTypedData_v4 signature over the typed data prepare_purchase returned.

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), and setApprovalForAll (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 inner data. 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 shape prepare_purchase reports it.
tools/call
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_id string · requiredFrom prepare_purchase.
  • tx_hash string · optionalThe buyNFT transaction hash (32 bytes). Not the approve — that hash succeeds but carries no purchase, and answers receipt_mismatch.
  • failed boolean · optionalTrue if the purchase was abandoned or rejected before submission.
  • reason string · optionalWhy, when failed is 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. A warning rides along when it is not — the lien is owned but CANNOT BE REDEEMED until setApprovalForAll succeeds.
  • released, reverted, previous_state, noteOn a revert or a failed: true report: the reservation is released so the wallet may prepare another purchase, and acknowledgment says whether the pending acknowledgment was marked failed (only when THIS reservation created it).
tools/call
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_proof object · requiredThe agent-keyproof-v1 signature over the authorization digest, from the agent wallet.
  • agent_consents array · requiredOne { specVersion, signature, documentType, version, timestamp } per required document, signed from the agent wallet over the version the API publishes.
  • label string · 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 as POST /agents/register returns it.
  • noteKeep sending the same bearer; the wallet-scoped tools work from now on.
tools/call
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.

CodeWhat it meansWhat to do
invalid_argumentsThe arguments failed validation.Fix the call against the schema in tools/list. Never retry it unchanged.
not_registeredNo 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_malformedThe 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_mismatchThis 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_revokedThe operator revoked this authorization.Stop. A new authorization is required.
authorization_expiredThe authorization’s term ran out.Stop. Ask the operator to re-authorize.
authorization_unavailableThe credential could not be CHECKED — an outage on our side, not a bad credential.Retry shortly. Do not re-authorize.
authorization_requiredREST 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_capsThe authorization carries no spend cap.Ask the operator to re-authorize; a cap is part of what they sign.
binding_mismatchThe wallet was re-registered under a newer authorization since this request started.Retry with the current bearer.
lien_not_foundNo lien has that id.Check the id; search_liens returns real ones.
lien_not_purchasableThe lien exists but cannot be quoted or bought — delisted, lapsed or matured. The message says which.Pick another lien.
cap_exceeded_per_purchaseThe 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_requiredThe OPERATOR has not accepted the current LienFi agreements.Surface it to the operator; that acceptance happens in a browser.
agent_consent_requiredThe 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_unavailableA required agreement is unpublished on our side.Retry later. Not your fault.
sanctioned_addressThe wallet is on a sanctions list.Stop. Terminal.
sanctions_screening_unavailableThe sanctions oracle could not be read; the screen fails closed rather than passing.Retry shortly.
purchase_in_flightThis 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_unavailableThe 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_ceilingThe 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_fundsThe wallet cannot cover the total; shortfall_usdc says by how much. Nothing was reserved.Fund the wallet and prepare again.
affordability_unavailableThe balance could not be read. It is never assumed affordable.Retry shortly.
reservation_not_foundNo reservation with that id belongs to this binding — one answer for every miss.Use the reservation_id prepare returned to you.
reservation_expiredThe reservation lapsed, was released, or its window passed.Start again with prepare_purchase.
reservation_settledThe reservation already settled on chain. Nothing reported now can undo that.Nothing to do; my_positions shows the lien.
reservation_not_quotedA 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_invalidThe 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_pendingThe transaction is not mined yet.Retry report_purchase once it confirms.
receipt_unavailableThe chain could not be read. The transaction MAY still be in flight.Call my_positions before retrying anything.
receipt_mismatchThe 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_allowedA purchase tool was called inside a JSON-RPC batch.Send it as the only message in the request.
registration_refusedPOST /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_limitedThis binding exceeded the purchase tools’ budget of 30 calls a minute.Slow down and retry.
internalAn 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.