describe.net docs

describe — API and MCP, for agents

Markdown twin of https://describe.net/api.html. Same content, no chrome. Canonical HTML: https://describe.net/api.html

This same guide is also published at https://docs.describe.net/guides/api.md (rendered beside it as guides/api.html). It is not a second version: the documentation hub COPIES this file byte for byte — a test in the repo compares the bytes, so a copy that stops being verbatim turns the suite red. If the two ever do differ, the hub is the one that is behind, and https://docs.describe.net/build.json says by how much: its guides_source field names the commit its copy came from. The full documentation index — every guide, the frozen schema, Swagger and ReDoc — is https://docs.describe.net/.

describe is an ERC-8004 reputation index over every chain we read. It does not sell you a score — the chain is public and you can average it yourself. It sells you what a score is made of: how many distinct counterparties wrote it, how many times each one spoke, in which facet, dated on-chain, each with its transaction.


The offer, in four lines

What you buy Composition. Distinct raters, concentration, self-rating gap, per-facet scores, per-chain split, activity — in one response, with the policy_version that produced it.
What it costs 0.01 USDC per wallet lookup. The full vet (lookup + history + every rating with its transaction) is 0.06. A citable, replayable receipt adds 0.05. Reconnaissance is free.
How you pay x402. No account, no API key, no signup. The first call answers 402 with the challenge; you sign an EIP-3009 USDC authorization and repeat the same call. The facilitator pays the gas.
Why it is worth it The full vet is 0.12 % of a 50 USD bounty. Doing it yourself is hours of RPC across every chain, and it still leaves you resolving ownership and inventing a scoring policy.

Prices in this file are a snapshot. The authority is GET /pricing, and above that, the 402 challenge of your own call — the price can depend on the parameters, which a per-route table cannot express.


Start here: the minimum

base_url            https://api.describe.net
openapi             https://api.describe.net/openapi.json
auth                none — no keys, no registration, no OAuth
payment             x402 · EIP-3009 · USDC · the facilitator pays the gas
treasury (pinned)   0xe4dc963c56979E0260fc146b87eE24F18220e545
curl https://api.describe.net/health
curl https://api.describe.net/pricing
curl https://api.describe.net/leaderboard
curl https://api.describe.net/stats/facets
curl https://api.describe.net/reputation/wallet/0x715dc035ffb97dd7bb4095c6670138ba05bb4e6d

The first four are free and answer 200. The fifth answers 402 with its challenge — amount, token, recipient and supported chains. That is the intended path, not an error.

Free

  • GET /search/{query}the one box: paste what you have. An EVM address, a Solana base58 id, or an ERC-8004 agent id. Answers interpretations (what the shape allows) and matches (what the index found), one row per chain, each with the wallet that owns it there, its agent_ids, final_score and the detail_route to pay for its decomposition. It never answers in the singular, and that is not caution: the same numeric id exists on up to ten chains at once and — measured 2026-08-23 over 26,736 ids present on two or more chains — not one has the same owner on all of them. In Solana a wallet and a Metaplex Core asset are the same syntax, so a base58 string gets both readings, labelled, without the route picking one. Exact match only: no prefix, no wildcard, no listing, no paging, no name search. A well-formed query the index has never seen is 200 with matches: [], never 404; only a query with none of the accepted shapes is 422 with accepted_shapes.
  • GET /wallets/{wallet}/chains — per-chain ERC-8004 identities and reputation for one wallet: agent_ids, final_score, total_reviews, distinct_raters, plus caveats[] (see "Caveats"). The preview before paying for /reputation/wallet/{wallet} — if nothing is here, there is nothing to buy.
  • GET /badge/{wallet}.svg — one embeddable SVG per wallet: the global score, how many distinct raters it is made of, and the date of the data, inside the pixel. An <img>, not a widget: it cannot script, cannot track and cannot be clickjacked. Cached an hour at the edge. Full guide: https://describe.net/badge.md
  • GET /health — liveness plus per-chain index coverage
  • GET /pricing — every tier, its price, and the reason for it
  • GET /manifesto — the manifesto as data: principles wired to the code that enforces them
  • GET /chains and GET /chains/{network} — per-chain aggregates: which chain to look at before paying to look at a subject
  • GET /feed — the newest ratings across every chain, newest first
  • GET /leaderboard — the first page (100 rows). Takes no parameters: a query param answers 422 pointing at GET /leaderboard/page, the metered paging route
  • GET /stats/facets — the index-wide facet table
  • GET /stats/types — breakdown by declared type
  • GET /stats/issuerswho wrote the index. Ratings grouped by the host of their feedbackURI, with distinct_agents and distinct_raters per issuer, plus a no_host row for the ratings that carry no URI. Free on purpose, and the reason is uncomfortable: this is the route somebody audits us with — how much of the index was written by products of our own house. Advisory, and it never moves a score.
  • GET /openapi.json — the schema; Swagger UI at https://api.describe.net/docs, ReDoc at https://api.describe.net/redoc
  • POST /a2a — the same free preview over A2A JSON-RPC (message/send), for an agent that speaks A2A rather than HTTP. Agent card at /.well-known/agent-card.json. Full guide: https://describe.net/a2a.md

Metered

Route USD What it answers
GET /reputation/wallet/{wallet} 0.01 Reputation of a wallet on every chain we read, with its composition
GET /reputation/wallet/{wallet}/history 0.03 How the score moved, dated by on-chain time
GET /reputation/agent/{network}/{agent_id} 0.02 One agent and every rating with its txHash, revoked ones included and marked
GET /reputation/rater/{wallet} 0.01 The other side of the grain: the wallet as a rater. Totals emitted, distinct subjects, dispersion, per-chain split, favourite-subject share, role breakdown from tag1. It emits no score — ranking raters would be a new verdict
GET /reputation/wallet/{wallet}?snapshot=true 0.05 Writes a durable receipt row with inputs_digest and policy_version
GET /leaderboard/page 0.01 Paging with offset, widening limit (≤500) or filtering by type — every call

/reputation/rater/{wallet} is the one metered route with no free_preview in its 402, and the absence is deliberate: /wallets/{wallet}/chains previews the wallet as a subject, which is a different question from the wallet as a rater. Offering it there would preview an answer to a question you did not ask.

Why the split: free is what one cached run serves everybody — the aggregate index has exactly one answer per cache window — plus the number and the location of a named subject (/search/{query}, /wallets/{wallet}/chains), which come off materialised views. Metered is the decomposition of that subject: who rated it, how many times each one spoke, in which facet, dated, with its transaction — where the key space is the whole index and the long tail never hits a cache. Until 2026-08-23 this paragraph drew the line at "the question about one named subject"; that was already inexact for the free per-chain gate, and /search moved it for good. Charging for /search would also mean billing typos: the paywall runs before any validation, so a mispasted string quotes exactly like a good one.


The decision loop

The window that matters is between GET /tasks/{id}/applications and POST /tasks/{id}/assign — routes on your own task marketplace (e.g. Execution Market), not on describe. After the assign, the EIP-3009 signature has already picked the worker — the nonce includes the receiver — and the money is committed.

  1. Ask for the free list and keep the candidates. GET /leaderboard carries final_score, shrunk_score and distinct_raters in the same row, so you can discard without paying.
  2. Buy the composition of every finalist. GET /reputation/wallet/{wallet}.
  3. Apply the five rules below. Do not look at the number.
  4. Read the facets of the same object — the receipt is reused for the same canonical resource (X-Payment-Reused).
  5. If you need it: history and evidence. Before using /history, read coverage.undated_reviews: if it is high, the series is no good for deciding.
  6. If you owe somebody an account: ?snapshot=true. What you buy is not the number, it is the commitment to the number.

The five rules

If in the response… Then Why
concentration == null do not decide with this Not "no concentration" — the signal is degrading. The API degrades the secondary aggregate rather than failing the whole response.
distinct_raters < min_raters no evidence, which is not zero 7,577 of 15,763 scored wallets have exactly one rater, and 22 % of those average a flat 100 — against 1 % among those with three (measured 2026-08-10).
total_reviews / distinct_raters >= campaign_per_rater campaign: discard Over the 3,000 best-ranked wallets the median is 1.00 and the p90 is 2.0. Careful: top_client_share alone does not catch this — the largest campaign in the index has a share of ~0.14 (measured 2026-08-11).
top_client_share >= top_share one single voice Agent base/888 has 1,200 ratings where one rater wrote 98.4 %. It scores 99.23 (measured 2026-08-11).
self_rated.gap > 15 it overrates itself Self-rating stays out of the average but is published with its gap. This cut is yours: reading_policy.self_gap is null because the index publishes the gap and does not judge it.

The names in the left column are fields of reading_policy, in the free GET /health — the same object the paid endpoint reads to fill caveats[]. They are named rather than typed here on purpose: until 2026-08-26 this table carried its own copies and they had already drifted from the code that charges for them. Each rule fires on its own (reading_policy.combine is "independent"); requiring two at once is a blind filter, not a strict one.

These five rules are not a traffic light. They filter what is grossly broken. The facets are where you look once a candidate passed the filter.

caveats[] — the same rules, already fired

Every response that carries a score carries caveats[]: the traps these numbers trigger, written out, derived from the fields of that same response and never from a second query — which is why it costs nothing extra.

Since 2026-08-28 each entry is an object, not a string:

"caveats": [
  { "code": "burn-address",
    "text": "Esta wallet es una direccion de quema bien conocida: …" }
]

(measured live on the free door, 2026-08-30: curl https://api.describe.net/wallets/0x000000000000000000000000000000000000dead/chains)

The contract, and it is the point of the field: text may change without notice — be reworded, re-measured, even translated. code never changes. Branch on caveats[].code, never on caveats[].text. The codes carry no version and do not bump policy_version, because a caveat is advisory by construction: a code names a cut, it does not move a score or a price.

The eight codes are the whole set, and it is frozen by a test — adding or renaming one is deliberately red:

code Fires when
no-score there is no score to read; null, never zero
concentration-degraded concentration came back null — the signal is down, not absent
single-rater exactly one distinct rater
few-raters below reading_policy.min_raters
top-client-share at or over reading_policy.top_share
campaign-per-rater at or over reading_policy.campaign_per_rater ratings per rater
self-rated the subject rated itself; the gap is published, not judged
burn-address the subject is a known burn address — real on-chain ratings about something nobody controls

Two things that bite:

  • An empty list is not "verified clean." It means no trap fired on the fields present in this response.
  • On the free GET /wallets/{wallet}/chains the list is a SUBSET — only what is computable from the public aggregate (today: burn-address). An empty caveats[] there does not promise that the paid decomposition is quiet.

Paying with x402

  1. Read the challenge. It carries amount, token, the recipient and supportedChains. Take the values from there, never from a cached table.
  2. Verify the recipient against the pinned one. The only address this service ever asks to be paid at is 0xe4dc963c56979E0260fc146b87eE24F18220e545. If the challenge names another address, do not pay: either it did not come from describe, or the treasury changed and the server did not find out. Raw HTTP does not make this comparison for you — the body of the 402 carries recipient, amount, token and supportedChains, plus accepts[] (one entry per network, each with its own payTo), pricing, next_action, price_usd, and, for a wallet-subject route, free_preview. Pin the address in your own code.
  3. Sign an EIP-3009 TransferWithAuthorization for that amount, to that recipient, on one of those chains, base64-encoded.
  4. Repeat the SAME call with the header. Same arguments, one more field. The nonce is consumed on settlement, so a credential that already paid does not pay twice. A 4xx after paying is almost always a spent credential or one signed for a different amount: ask again without payment, read the new challenge, sign against that one.

Displaying a score — the canonical format

Two decimals, trailing zeros trimmed. 86.65, 84.7, 87 — never 82.0.

Fixed on 2026-08-29 after the ecosystem's three consumers each rendered the same number differently (86.653045, 86.7, 86 — one field, three strings), and decided by measurement, not taste: over 47 real distinct scores, rounding to 0 decimals merges 23 pairs of different agents into identical strings, 1 decimal merges 4, 2 decimals merges 1. And trimming matters as much as the count: two surfaces both "agreeing on 1 decimal" still printed 82.0 and 82 for the same agent.

The canonical one-liner, byte-identical across the two:

String(parseFloat(x.toFixed(2)))   // JavaScript
f"{round(x, 2):g}"                 # Python

Pin it with one test against these cases: 86.653045 → "86.65", 84.7 → "84.7", 87 → "87", 82.0 → "82".

Live witness case (verified across three independent surfaces on 2026-08-29): the agent scored 83.0 renders as 83 on all three — where toFixed(2) alone would print 83.00 and toFixed(1) would print 83.0. It is the one value that tells the three candidate rules apart, which is why it is the case to test first.

This is a display convention: the API keeps serving the full-precision number (six decimals), and what you compute with is the number, never the string.

Measured traps

  • No ratings ≠ zero. "There is no evidence" and "they were rated badly" are different facts. The field arrives null and the index refuses to collapse them.
  • declared_type is not a type. 283,770 of 470,064 agents (60.4 %, measured 2026-08-30) are unknown, and the second largest "type" is the URL of the EIP schema — with its typo variant. ERC-8004 has no type field. Never treat it as verification. (Until 2026-08-30 this line read "69,517 of 82,835 agents (84 %, measured 2026-08-11)". That was true when it was written and it stayed legal — it carried its date — but the index has grown 5.7× since, and the share had moved 24 points. The correction stays written: a dated figure does not stop being stale, it only stops being a lie. Read it live from GET /stats/types.)
  • tag1 is free text on-chain. The longest facet in this index is 471 characters: a paragraph about gardening used as a label. Escape everything that comes from the chain.
  • undated_reviews > 0 means the last point of the series can legitimately sit below the profile's final_score. Neither is a bug.
  • The leaderboard does not order by average. It orders by the Bayesian mean. shrunk_score and distinct_raters travel in the response so the order can be recomputed by hand.
  • The policies are versioned separately. policy_version is the score's. ordering_policy, rater_weight_policy and confidence_policy do not move a single score; merging them would mark every rating as recomputed each time a list changes. GET /health is the authority on how many there are — this line has already been wrong once, in the same batch that added the fourth.

What is broken

Honesty about our own state, because a reputation index that makes up its own status has no authority to decompose anybody else's.

  • ~~GET /stats/types fails at the origin after ~25 s~~ — does not reproduce; corrected 2026-08-30. The bullet is kept, struck through, because deleting it would hide that this page published a false status about itself — which is the one thing a reputation index cannot afford to do quietly. It was re-measured with exactly the discriminating check the bullet itself asked for — a cache-buster, so the edge cannot answer for the origin:

    curl -s -o /dev/null -D - "https://api.describe.net/stats/types?cb=$RANDOM$RANDOM"
    

    Three consecutive runs on 2026-08-30, every one X-Cache: Miss from cloudfront (so every one went to the origin): HTTP 200 in 0.229 s · 0.190 s · 0.198 s, full body, total_agents: 470064. The route is on the critical path again. The lesson survives the bug: a 200 with an Age header proves nothing about the origin — that check is still how you tell a live route from a warm cache, here and anywhere else on this API.

  • Cold latency is seconds, not milliseconds. The 402 short-circuits before any SQL runs, so the challenge comes back fast; the paid response does the work. Ask for the vet before you open the assignment window, in parallel with everything else — not inside it.

The live version of this list is measured in your own browser, per endpoint, at https://describe.net/api.html#status.


MCP

Stdio tools wrapping the same endpoints, priced like the routes behind them: the tools that wrap free routes are free, the tools that decompose a named subject are metered. describe_pricing is free and explains all the others; describe_check_wallet is also free and answers GET /wallets/{wallet}/chains — whether a wallet has anything worth paying for, before you spend on describe_lookup_wallet. When you do not even know what you are holding, describe_resolve is free too and answers GET /search/{query}: what the string was read as, and one row per chain with the wallet that owns it there. Fourteen tools (measured 2026-08-30 with tools/list against the live server): six are priced like the routes behind them; the other eight never charge — leaderboard and facets also have free variants (first page / no wallet). (This line said "thirteen: five priced" until 2026-08-30. It was counted on 2026-08-23 and was true then; describe_rater_profile shipped on 2026-08-29 and no count written in prose turns red on its own.) Reproduce it yourself in one call:

curl -s -X POST https://api.describe.net/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

The server card is the live list — this count is dated, the card is not; call describe_pricing first and it tells you what exists.

{
  "mcpServers": {
    "describe-net": {
      "command": "python",
      "args": ["/absolute/path/to/the/repo/mcp/server.py"],
      "env": { "DESCRIBENET_API_URL": "https://api.describe.net" }
    }
  }
}

That /absolute/path/to/the/repo is literal: the repository is not published, so this config only works if you already have the code. In the meantime the tools do nothing the GET calls above cannot — the server is a wrapper, not a capability. It does not sign, does not custody, cannot move funds, and computes nothing: no scoring rule lives there, every tool calls the canonical endpoint. Server card: https://describe.net/.well-known/mcp/server-card.json

WebMCP

If you visit api.html with a browser, seven tools are registered on the page via navigator.modelContext.provideContext(): describe_net_index_status, describe_net_top_wallets, describe_net_price_quote, describe_net_check_wallet, describe_net_lookup_wallet, describe_net_wallet_by_chain, describe_net_open_profile. describe_net_check_wallet is free and answers whether a wallet has anything worth paying for. The metered ones return the 402 challenge with the recipient already compared against the pinned treasury; they cannot sign and hold no keys.


Twin files AND content negotiation — you have both

Content negotiation works. Ask for /, /index.html or /api.html with Accept: text/markdown and you get the markdown twin:

curl -s -o /dev/null -w '%{content_type}\n' -H 'Accept: text/markdown' https://describe.net/api.html
#   text/markdown; charset=utf-8            (measured 2026-08-30)
curl -s -o /dev/null -w '%{content_type}\n' https://describe.net/api.html
#   text/html                               (control, same URL, no header)

The response also carries Vary: Accept, Accept-Encoding, which is what keeps a third-party cache from serving one to the other.

(Correction, 2026-08-30: until today this section opened with "Accept: text/markdown on api.html returns HTML … S3 does not negotiate content", and it was wrong from 2026-08-21 onward — the day the CloudFront Function shipped. llms.txt had said the opposite, on this same site, for nine days. The line mattered more than most: it is the one an agent reads before deciding not to try. The analysis below is kept unchanged, because it is still why the feature was built the way it was.)

The twins also stay at fixed URLs (/api.md, /auth.md, /skill.md, /workflows.md, /badge.md, /a2a.md, /index.md, /manifesto.md), and that remains the most reliable way to fetch them — no header, no negotiation, no cache subtleties. The two mechanisms coexist on purpose:

  • A twin .md file. Zero infrastructure change, zero added latency, and CloudFront already serves .md as text/markdown; charset=utf-8. Cost: storage measured in kilobytes.
  • A CloudFront Function on viewer-request rewriting the URI when Accept contains text/markdown. Costs about USD 0.10 per million invocations — which is not the real cost. The real cost is the cache: naïvely this requires adding Accept to the cache key, and getting that wrong poisons it — whoever warms an object first decides what everybody else receives, HTML or Markdown, at random. This site has already eaten that exact bug once with the Origin header on /leaderboard and /stats/*, where the edge cached without Origin in the key and browsers got a response with no CORS header. Splitting the cache key also halves the hit rate for every object. What ships instead rewrites the URI before the cache key is computed, so the agent and the browser land on two different objects and neither can be served the other's bytes. That is the whole trick, and it is why the twin files had to exist first: only a path with a real twin is ever rewritten — promising markdown for a page with no twin would be an announced 404.

Discovery for this file: <link rel="alternate" type="text/markdown" href="/api.md"> in the HTML head, plus /llms.txt.


  • https://docs.describe.net — the documentation hub: this guide and the others, plus the rendered OpenAPI (Swagger and ReDoc) and a citable snapshot of the schema
  • https://describe.net/auth.md — how to authenticate (you do not; payment is the auth)
  • https://describe.net/badge.md — the embeddable badge: one SVG per wallet, how to embed it and what it does not do
  • https://describe.net/a2a.md — the A2A door: message/send over JSON-RPC, the agent card, and what it answers
  • https://describe.net/skill.md — the umbrella skill: the whole service as an installable playbook
  • https://describe.net/workflows.md — satellite workflows that build on the umbrella skill
  • https://describe.net/.well-known/agent-skills/vet-a-counterparty/SKILL.md — the focused vet loop: from describe_pricing to a citable receipt, with the checks in between
  • https://describe.net/.well-known/api-catalog — RFC 9727 linkset
  • https://describe.net/llms.txt

Verbatim source: api.md · from site/api.md at git:b5eb83903e36 · back to the documentation portal