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. Suggested wallet: PayBox — install it or check it is in use, then its pay_x402 signs the challenge (paybox.md).
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
wallet (suggested)  PayBox · https://describe.net/paybox.md
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 /names/resolve?name={name} and GET /names/reverse?address={address} — on-chain names, both ways. ENS (names, subnames, CCIP-Read), Basenames (*.base.eth), DNS names imported into ENS, Unstoppable and Avvy (.avax), read on-chain by describe.net with the uvd-describe-sdk resolver; .sol is detected and answered unsupported_system for now. Always 200, with the outcome inside: error is null or one of not_found, invalid_name, reverse_mismatch, expired, unsupported_system, rpc_unavailable, and tried names the systems that were asked. A reverse name is returned only if it resolves back to the same address. The chain is never yours to pick: each route takes its one parameter, and anything else is 422. It never reads the reputation index — resolve here, then look the address up. The two routes share one small, GLOBAL rate-limit budget, not a per-client one: when it is spent the gateway answers 429 to every caller until it refills, and answers cached by CloudFront do not consume it (read RateLimit-Policy on their responses)
  • 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 /reputation/by-resource?url={resource_url} — who collects at an x402 resource, asked by its URL — never an address back. describe.net reads the payTo of each accepts[] entry from the facilitator's public bazaar (GET https://facilitator.ultravioletadao.xyz/discovery/resources) and answers one row per collector: its CAIP-2 network, identity_status (erc8004_identity · no_erc8004_identity · not_indexed), has_identity, identity_count, the agent to link by id ({network, agent_id}, for GET /reputation/agent/{network}/{agent_id}) and reviews — computed from the same facts as /wallets/{wallet}/exists (identity, ratings, distinct raters and the date of the last rating that counts). no_erc8004_identity is not bad reputation: the collector's address owns no identity on a chain the index reads, which is the common case for bazaar endpoints (ownership only — an agent paid at a wallet that is not its owner reads as no identity). The URL is a lookup key, never fetched, and never echoed back: resources[i] answers the i-th url. Only the bazaar row stored under exactly that URL counts (scheme and host case-insensitive, default port and your fragment ignored; if that row does not exist, the URL exactly as written). collectors_checkable says whether a found listing's collectors could have been compared with the live endpoint at all: only an http listing that is alive — necessary, not sufficient (the facilitator skips the comparison when the 402 is unreadable, and never makes it for an MCP listing); false means they are only what whoever registered the URL declared. Per URL status is found, not_in_bazaar (every candidate came back in one read from the bazaar replica that answered), lookup_truncated (more candidates than one read holds and the URL was not among those read — not proof of absence), quarantined (the bazaar quarantined the listing; its collectors are not described) or bazaar_unavailable (ask again; never cached). Repeat url up to 10 times for a batch — and do: the route has one small, GLOBAL rate-limit budget on each of its route keys (GET and HEAD, with and without /v1), not a per-client one, because it waits on the facilitator on the same capacity the paid routes use; when it is spent the gateway answers 429 to every caller until it refills (read RateLimit-Policy on its responses). Built for directories and routers that must never hold a payment address.
  • 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/issuers — who 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 /categories — the directory's catalog. The few categories that can already be pointed at in the index, each with a stable slug (at most 15 bytes), its label and definition in English and Spanish, the tag_suffixes it counts and the measurement that admitted it — plus the offer measured live: providers_at_least, a lower bound of distinct subjects rated as executors there (null means no offer, never zero), and coverage, how much of the index carries a category at all. Alphabetical by slug, because the order of a directory is never sold. No people, no geography, no score per category; catalog_version versions the data and GET /health says which copy is in force.
  • 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, and NOT the row below one single voice Agent base/888 has 1,200 ratings where one rater wrote 98.4 %. It scores 99.23 (measured 2026-08-11).
top_client_share >= top_share, top_client_index_subjects >= top_client_reach and top_client_ratings <= top_client_burst × (top_client_index_ratings / top_client_index_subjects) the shape of one sensor — still one source The second half of the row above, split on 2026-09-20 because it was the larger half: measured on the live index, 41,719 of the 43,711 subjects the share cut marked (95.4 %) are this. Both halves or neither. Fan-out alone is a number an attacker owns end to end — rate 100 subjects once each and a farm reads as a scanner — and the pilot's hand-labelled corpus already contained one that did (base/18132: fan-out 808, but 17 ratings here against a 1.2 global mean). The second half asks whether that rater behaved here the way it behaves everywhere.
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.

These codes are the whole set, and it is frozen by a test — adding or renaming one is deliberately red, and tests/test_caveat_codes_publicados.py ties this table to the code:

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, and the next row does NOT apply: one actor concentrated on THIS subject, which is the wash-trading shape. ⚠️ Narrowed on 2026-09-20 — before that date it also fired on scanners, and that was 95.4% of its marks (see the next row). If you branch on this code, you are getting the same shape with far fewer false alarms. It still fires on a wide-reach rater that broke its own rhythm here — fan-out does not buy an exemption on its own
top-client-scanner same share cut as the row above, different reading, and it takes two measurements of the top rater, never one: its fan-out (concentration.top_client_index_subjects) is at or over reading_policy.top_client_reach, and what it wrote here (concentration.top_client_ratings) is within reading_policy.top_client_burst times what it leaves on a typical subject (top_client_index_ratings / top_client_index_subjects). That is the shape of a scanner's telemetry. Measured on the live index 2026-09-20: of the 43,711 subjects the share cut marked, 41,719 (95.4%) are this. It is not an all-clear, and it is not a verdict about intent — the shape can be bought by rating hundreds of subjects at the same rate first, one sensor is still one source, and the number still has no second voice behind it
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
thin-chain the wallet spans two or more chains and at least one of them is held up by fewer than reading_policy.min_raters people. Names each thin chain with its rater count and its weight — the distinct_raters / (distinct_raters + m) credibility that chain carries in the global score. A one-person chain weighs little, never zero
facilitator-authored agent route only (GET /reputation/agent/{network}/{id}): at least one row of ratings[] has author_class: facilitator-authored — the facilitator's wallet relayed it, so that row's client is not the counterparty and any per-rater count merges all such rows into one. Carries no numbers: nothing is aggregated by author
brigading-suspected agent route only, and off by default (GET /health → brigading.caveat_enabled): the rater set has the shape of a coordinated burst — many DISTINCT wallets with one rating each, most of them with no prior history, arriving together. This is the OPPOSITE of what top-client-share and campaign-per-rater measure, and the two populations barely overlap: measured over the whole index on 2026-09-18, concentration marked 10,252 subjects, the burst 540, and the intersection was 13. Carries the probability and a suggested reading weight; it never moves the score

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 and, since 2026-09-05, thin-chain — every row carries distinct_raters and weight). An empty caveats[] there does not promise that the paid decomposition is quiet.
  • The free route says what it did not evaluate. Since 2026-09-14 it carries caveats_not_computed: the codes the paid decomposition evaluates and that answer did not — by name, never with their outcome. Treat every code listed there as unverified, never as passed.
  • Who signed each rating. On GET /reputation/agent/{network}/{id} every row of ratings[] carries author_class: facilitator-authored when client is a known relayer (today the x402 facilitator's EVM wallet), rater-authored otherwise — which does not prove who signed. Per row only: nothing is scored or counted by author.

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. PayBox first — the wallet this service suggests: if it is in use, its pay_x402 takes the challenge's accepts[] verbatim (x402_version: 2); if not, install it (connector https://api.paybox.sh/mcp, or the @paybox-sh/sdk CLI). Guide and known limits: paybox.md. After it: any other MCP wallet (ows_sign_eip3009) or uvd-x402-sdk.
  4. Repeat the SAME call with the header. Same arguments, one more field — X-PAYMENT over HTTP, the only payment header read here. 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 — except a 409 or a 503, a 402 whose error is PAYMENT_SETTLEMENT_FAILED, or any 4xx that carries X-Payment-Receipt. After the first three the authorization may already have moved money, so follow that answer's next_action and auth.md before signing anything (step 7 of paybox.md). With X-Payment-Receipt the payment settled and the error is in your request — a 422 for a malformed parameter, say, because payment is taken before the arguments are validated: fix the arguments, and do not sign another authorization for that same broken request.

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. Fifteen tools: six are priced like the routes behind them; the other nine never charge — leaderboard and facets also have free variants (first page / no wallet). Fourteen were measured on 2026-08-30 with tools/list against the live server; the fifteenth, describe_categories (the directory's catalog, free), was added on 2026-09-14 and is counted by the test suite, not yet by a live run. (This line said "thirteen: five priced" until 2026-08-30 and "fourteen: eight free" until 2026-09-14. Each was true when counted; 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/paybox.md — the suggested wallet: install PayBox or check it is in use, pay and sign with it here, and its known limits
  • 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

SDKs

You never have to hand-roll this API:

Package What it is Install
uvd-describe-sdk (Python: pip install uvd-describe-sdk) Reputation client (TS/JS + Python, same contract). Score never travels without composition; null never 0; failures cached as failures; caveats passed through uninterpreted with scope. npm i uvd-describe-sdk
uvd-x402-sdk x402 payments (TS) — sign and settle the 402 challenges of the paid routes. npm i uvd-x402-sdk
uvd-x402-sdk (Python) Same, Python — plus build_uvd_feedback_params (v0.71+), the TAGGING-UVD emitter that fails loud where the reader would degrade silently. pip install uvd-x402-sdk

Zero-code: the badge and the copy-paste widget.

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