putty
API ReferencePublic

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.

GET
/public/entities/{chain}/{address}

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.

Path Parameters

chain*string

The @putty/chains slug for the chain the token is on. An unknown slug is a 400 naming the slugs that exist.

Length1 <= length
address*string

The ERC-20 token contract to read. A 20-byte hex address; anything else is a 400.

Length1 <= length

Response 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"      }    ]  }}