Read one token as a public entity
One call that answers everything a person needs **before** they buy a token: the Scan, the Verify label that ships attached to it, the Receipts document, and whether this token's creator published a site to the public showcase. It exists so Explore does not have to fan out over four endpoints that can each be down on their own. **It composes; it does not re-read.** Every sub-object is the value an existing module's service returned, served in that module's own schema: `scan` is `TokenScan` from `GET /scan/token/{chain}/{token}`, `receipts` is `TrustReceipts` from `GET /trust/receipts/{chain}/{token}`, and `site`/`siteEntries` are `PublicShowcaseEntry` objects from `GET /showcase`. A client that already renders those surfaces renders this with no new types. ## Every section says whether it was read `data.receipts` is either the document or `{ code, message }`, and `data.siteEntries` is either the matching entries or `null`. **A section is never silently omitted**, because a client cannot tell "there is nothing here" from "we did not ask" — and on a page about whether to trust a token, that difference is the whole product. A partially-readable entity is a normal 200, not an error: one upstream being down costs the caller one tab, not the page. `not_a_token` and `read_failed` are different sentences and are not interchangeable: the first is a claim the upstream made **about the address**, the second is a claim about putty's plumbing or an upstream's uptime. A read that failed is never rendered as a zero, an empty answer, or a field that is simply gone. **`siteEntries: []` is a real answer, and it is not `null`.** An empty array means this creator published nothing here. It never means "no site exists" — no public read in this codebase is entitled to make that claim, because a project's own build origin belongs to its owner. `site` is the first entry of `siteEntries`; both are served so a client does not have to know which read produced the match. ## No market figure, and that is deliberate The domain this implements says "scan + verify + receipts + market". **The market figure is left out, and there is no `market` key in the response.** The only market source this codebase has is the Dexscreener tool behind `/mcp`, and `/mcp` is behind `requireAuthOrApiKey` — so a price here would mean a bearer in an unauthenticated server fetch, i.e. a session minted on every anonymous page load. The token page reaches the same conclusion and fetches price in the browser, after the reader asks for it (`apps/website/app/token/[chain]/[address]/page.tsx` says why). A field that is not there is a field a client can see is not offered; a `null` would be a field it has to learn to distrust. An entity read without a price is the honest version of this domain. ## Cache-Control is short, and deliberately not `immutable` The response carries `Cache-Control: public, max-age=30`. A token's scan changes when its owner can mint, its receipts change when the creator ships a new module, and its published site changes the moment a creator opts in — this is a read of live onchain and opt-in state, not a static asset. Thirty seconds is short enough that a browser does not pin a stale security verdict across a session, and long enough that a burst of page views does not re-run three chain reads each. **`immutable` is wrong here and would be a bug.** `GET /public/{key}` in the Storage module uses `public, max-age=31536000, immutable` and is right to: it serves bytes at a content-addressed key. This route does not, and copying that header would pin a stale price, a stale mint verdict, and a stale holder window in every browser that ever saw the response. Do not "optimise" this into `immutable`. ## Amounts are decimal strings end to end `scan.totalSupplyRaw`, every `balanceRaw`, and every percentage in `receipts` are strings. A 1e9 supply at 18 decimals is thirty digits and a double cannot hold it. Do not parse them into a number to display a total. **`clearedForMainnet` is always `false`**, in `scan` and in `verify`. It is declared as the literal `false` so the document states what the code enforces: no putty report, of any label, is an external audit. A `verified custom` label means every check ran and passed on the evidence putty had — read `verify.explanation` for the sentence that must be shown beside it. ## Statuses - **200** — the entity. Sections that could not be read are `null` with a reason. - **400** — the chain is not in the registry, or the address is not 20 bytes. Both messages name what is supported. - **404** — there is no contract at that address, so there is no token. Byte-for-byte the same shape `GET /scan/token/{chain}/{token}` answers with, and a real 404 rather than an entity full of nulls: a page of refusals for something that is not a token reads like a broken token. - **429** — the per-IP limit. This route is an unauthenticated aggregator that fans out to several upstreams, so it is limited exactly as the other public reads are. - **500** — the scan itself could not run, so the module cannot tell "not a token" from "we could not look". A 500 is reserved for that; everything else is a 200 with an honest section.
One call that answers everything a person needs before they buy a token: the Scan, the Verify label that ships attached to it, the Receipts document, and whether this token's creator published a site to the public showcase. It exists so Explore does not have to fan out over four endpoints that can each be down on their own.
It composes; it does not re-read. Every sub-object is the value an existing
module's service returned, served in that module's own schema: scan is
TokenScan from GET /scan/token/{chain}/{token}, receipts is TrustReceipts
from GET /trust/receipts/{chain}/{token}, and site/siteEntries are
PublicShowcaseEntry objects from GET /showcase. A client that already renders
those surfaces renders this with no new types.
Every section says whether it was read
data.receipts is either the document or { code, message }, and data.siteEntries
is either the matching entries or null. A section is never silently omitted,
because a client cannot tell "there is nothing here" from "we did not ask" — and
on a page about whether to trust a token, that difference is the whole product. A
partially-readable entity is a normal 200, not an error: one upstream being down
costs the caller one tab, not the page.
not_a_token and read_failed are different sentences and are not interchangeable:
the first is a claim the upstream made about the address, the second is a claim
about putty's plumbing or an upstream's uptime. A read that failed is never
rendered as a zero, an empty answer, or a field that is simply gone.
siteEntries: [] is a real answer, and it is not null. An empty array means
this creator published nothing here. It never means "no site exists" — no public
read in this codebase is entitled to make that claim, because a project's own build
origin belongs to its owner. site is the first entry of siteEntries; both are
served so a client does not have to know which read produced the match.
No market figure, and that is deliberate
The domain this implements says "scan + verify + receipts + market". The market
figure is left out, and there is no market key in the response. The only market
source this codebase has is the Dexscreener tool behind /mcp, and /mcp is behind
requireAuthOrApiKey — so a price here would mean a bearer in an unauthenticated
server fetch, i.e. a session minted on every anonymous page load. The token page
reaches the same conclusion and fetches price in the browser, after the reader asks
for it (apps/website/app/token/[chain]/[address]/page.tsx says why). A field that
is not there is a field a client can see is not offered; a null would be a field
it has to learn to distrust. An entity read without a price is the honest version of
this domain.
Cache-Control is short, and deliberately not immutable
The response carries Cache-Control: public, max-age=30. A token's scan changes when
its owner can mint, its receipts change when the creator ships a new module, and its
published site changes the moment a creator opts in — this is a read of live onchain
and opt-in state, not a static asset. Thirty seconds is short enough that a browser
does not pin a stale security verdict across a session, and long enough that a burst
of page views does not re-run three chain reads each.
immutable is wrong here and would be a bug. GET /public/{key} in the Storage
module uses public, max-age=31536000, immutable and is right to: it serves bytes
at a content-addressed key. This route does not, and copying that header would pin a
stale price, a stale mint verdict, and a stale holder window in every browser that
ever saw the response. Do not "optimise" this into immutable.
Amounts are decimal strings end to end
scan.totalSupplyRaw, every balanceRaw, and every percentage in receipts are
strings. A 1e9 supply at 18 decimals is thirty digits and a double cannot hold it.
Do not parse them into a number to display a total.
clearedForMainnet is always false, in scan and in verify. It is declared
as the literal false so the document states what the code enforces: no putty
report, of any label, is an external audit. A verified custom label means every
check ran and passed on the evidence putty had — read verify.explanation for the
sentence that must be shown beside it.
Statuses
- 200 — the entity. Sections that could not be read are
nullwith a reason. - 400 — the chain is not in the registry, or the address is not 20 bytes. Both messages name what is supported.
- 404 — there is no contract at that address, so there is no token. Byte-for-byte
the same shape
GET /scan/token/{chain}/{token}answers with, and a real 404 rather than an entity full of nulls: a page of refusals for something that is not a token reads like a broken token. - 429 — the per-IP limit. This route is an unauthenticated aggregator that fans out to several upstreams, so it is limited exactly as the other public reads are.
- 500 — the scan itself could not run, so the module cannot tell "not a token" from "we could not look". A 500 is reserved for that; everything else is a 200 with an honest section.
Path Parameters
The @putty/chains slug for the chain the token is on. An unknown slug is a 400 naming the slugs that exist.
1 <= lengthThe ERC-20 token contract to read. A 20-byte hex address; anything else is a 400.
1 <= lengthResponse Body
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/public/entities/string/string"{ "chain": "ethereum", "address": "string", "data": { "scan": { "chain": "ethereum", "token": "string", "observedAt": "string", "isContract": true, "name": "string", "symbol": "string", "decimals": 0, "totalSupplyRaw": "string", "mint": { "status": "measured", "value": { "verdict": "not_measurable", "holder": "string", "holderIsContract": true, "pausable": true, "explanation": "string" } }, "lpLock": { "status": "measured", "value": { "state": "not_measurable", "pair": "string", "locker": "string", "untilUnix": 0, "explanation": "string" } }, "fees": { "status": "measured", "value": { "feeHundredthsBip": 0, "puttyFeeBps": "string", "hook": "string", "hookFlags": "string" } }, "holders": { "status": "measured", "value": { "holders": "string", "topHolders": [ { "address": "string", "balanceRaw": "string", "percent": "string" } ], "topSharePercent": "string", "concentrated": true, "concentrationThresholdPercent": "string" } }, "simulation": { "buy": { "status": "measured", "value": { "amountIn": "string", "expectedOut": "string", "priceImpact": "string" } }, "sell": { "status": "measured", "value": { "amountIn": "string", "expectedOut": "string", "priceImpact": "string" } } }, "verifyLabel": "verified template", "verifyLabelExplanation": "string", "clearedForMainnet": false }, "verify": { "label": "verified template", "explanation": "string", "clearedForMainnet": false }, "receipts": { "chain": "string", "chainId": 1, "token": "string", "symbol": "string", "decimals": 0, "summary": { "moduleCount": 0, "immutableModuleCount": 0, "mutableParameterCount": 0, "minTimelockSeconds": 0 }, "versions": [ { "version": 0, "manifest": "string", "supersededBy": "string", "observedAt": "string", "current": true } ], "modules": [ { "module": "fee_split", "address": "string", "label": "string", "venue": "putty", "controls": [ { "name": "string", "mutable": true, "value": "string", "unit": "bps", "range": { "min": "string", "max": "string" }, "authority": { "address": "string", "role": "wallet", "proposer": "string" }, "timelockSeconds": 0, "pending": { "value": "string", "proposedAt": "string", "executableAt": "string" } } ], "recipients": [ { "address": "string", "shareBps": "string", "label": "string" } ], "changeLog": [ { "event": "string", "txHash": "string", "blockNumber": 0, "timestamp": "string", "from": "string", "to": "string", "actor": "string" } ] } ], "wallets": [ { "label": "string", "address": "string", "token": "string", "balance": "string", "role": "fee_recipient" } ], "observedAt": "string", "provenance": { "blockNumber": 0, "warnings": [ "string" ] } }, "site": { "id": "string", "slug": "string", "kit": "business", "title": "string", "summary": "string", "projectUrl": "string", "chain": "ethereum", "token": "string", "templateOptIn": true, "remixCount": 0, "publishedAt": "string" }, "siteEntries": [ { "id": "string", "slug": "string", "kit": "business", "title": "string", "summary": "string", "projectUrl": "string", "chain": "ethereum", "token": "string", "templateOptIn": true, "remixCount": 0, "publishedAt": "string" } ] }}Serve a public object by key GET
Returns the bytes of a public object with `Cache-Control: public, max-age=31536000, immutable`. The path captures the key after the `public/` prefix (e.g. `/public/tokens/<chain>/<address>/logo.png`). A key that does not exist in storage returns 404. The route is rate-limited per IP and unauthenticated; private objects are reachable only through the signed-url route on the blob store.
Search the catalog GET
Returns the catalog items the caller may see: every public item (`official` or `community`), plus the caller's own `private` items. Filters — `kind`, `templateId`, `tag`, `q` — AND together. Results are ordered by install count, then by recency. There is no `status` filter, because visibility is not something a caller chooses: a private item is invisible to everyone but its owner whatever they ask for.