API v1

API, MCP server and bulk exports

Everything Traceleaf holds — factory, exporter, US importer, brand, seller and the price at each step — is readable by machine three ways: a read-only REST API at /api/v1, an MCP server that puts the same calls inside Claude and other model clients, and 8 whole tables as CSV or JSON with no key at all.

That note is not only on this page. It travels inside every API response as note, inside every JSON export as a field and inside every CSV as a comment header, because a payload outlives the page it came from. Coverage today: 34 factories, 48 exporters, 30 importers, 46 brands, 260 sellers, 1,703 dated links and 18 published figures, as of 11 October 2026.

Keys and tiers

Present a key as Authorization: Bearer <key>, as X-API-Key, or as ?key= for a one-liner. Two endpoints — /api/v1/search and /api/v1/stats/{slug} — need no key at all, at a low rate limit.

The tier split is a product decision rather than a technical one, and it is worth stating in the open: naming the registers Traceleaf reads, with their URLs, publishes the collection method. So a free key still sees that evidence exists, how much of it, what kind of source it is and how fresh it is — the part a journalist or a consumer needs — while the register names and URLs are reserved for keyed callers on the team tier. The methodology page names every source category regardless.

TierCredentialRequests / hourWhat it may see
anonymousNo key30Search and the published stat figures. Nothing else.
freeFree key300Entity profiles, sellers and the graph. Each claim's evidence is summarised — how many records, what kind of source, which jurisdiction, when it was last pulled — without the register names or URLs.
teamTeam key5,000The same, plus full register citations (name, authority, field, URL, snapshot reference), the changes feed, watchlists and the alert preview.
feedFeed key50,000Team access at bulk rate limits, for a consumer syncing the whole dataset on a schedule.

Limits are a fixed window per key per hour, reported on every response as RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A rejected key is metered against the caller, not against the key it presented, so guessing costs what using the API costs. A bad key is never silently downgraded to the anonymous tier — it returns a 401 naming which of unknown, revoked or expired applies.

A request without a keyverbatim
$ curl -i "https://traceleaf.io/api/v1/brands/B001"
HTTP/1.1 401 Unauthorized

{
  "apiVersion": "v1",
  "error": {
    "code": "unauthorized",
    "message": "This endpoint requires an API key.",
    "remedy": "Sign up for a free key. `/api/v1/search` and `/api/v1/stats`
               are available without one at a low rate limit."
  },
  "docs": "https://traceleaf.io/methodology",
  "contact": "Traceleaf"
}

Endpoints

14 endpoints, all read-only but one. Every list takes ?limit= (default 25, max 200) and ?cursor= from the previous response’s nextCursor; the cursor is opaque and should not be parsed. /api/v1 itself is the machine-readable version of this table and needs no key, so a client can discover the surface without reading a page.

EndpointTierParametersReturns
GET /api/v1anonymous—The discovery document: endpoints, tiers, rate limits, coverage counts and the list of fields this dataset cannot fill. Served without a key.
GET /api/v1/searchanonymousq (required, ≥2 chars) · kind=brand,factory,exporter,importer,seller · limit · cursorRanked entities matching a name, alias, Chinese name, domain, unified social credit code or Traceleaf id. Each hit carries the field that matched and both its page URL and its API URL.
GET /api/v1/stats/{slug}anonymousslug (path)One published figure: its value, the question it answers, the sentence Traceleaf will stand behind, the basis of the calculation and the series behind it.
GET /api/v1/brands/{id}freeid or slug (path) · links=falseLicence position, trademarks, domains, hardware models, counts by neighbour kind, and the relationships with per-link confidence.
GET /api/v1/factories/{id}freeid or slug (path) · links=falseRegistry record, licences, stated capacity, patents, court record and the brands it supplies.
GET /api/v1/importers/{id}freeid or slug (path) · links=falseShipments by month, suppliers and the brands fed. State licence fields are withheld at every tier, pending legal review.
GET /api/v1/exporters/{id}freeid or slug (path) · links=falseShipments, HS codes, declared unit values and the linked manufacturer with the link's confidence.
GET /api/v1/sellersfreebrand (id or slug) · state (two-letter) · limit · cursorStores and sites on whose shelves or menus a brand was observed, each with whether a licence number is on file in that state and the date it was checked.
GET /api/v1/graph/{id}freeid or slug (path) · depth=1–4 (default 2)The entity's neighbourhood: nodes with their hop distance, and edges with kind, confidence, date range, inference note and the records behind each one.
GET /api/v1/changesteamsince (required, YYYY-MM-DD) · type=appeared,changed,disappeared · provenance=real,synthetic · entity · limit · cursorRows whose state Traceleaf observed to differ from the previous ingest run, with the run history and the storage backend behind the log stated in the payload.
GET /api/v1/watchliststeamid (optional)The watchlists this key owns, each with which stages of delivery are up.
POST /api/v1/watchliststeambody: label · entityIds (1–200) · email (optional)Records entities to watch. Unknown ids and malformed addresses are rejected rather than stored. The response names every stage between a change and an inbox, and whether it is up.
GET /api/v1/alertsteamsince · until (≤90 days apart)A dry run: the alerts and the weekly brief this key's watchlists would produce, composed in full. Writes nothing.
POST /api/v1/alertsteamsince · until (≤90 days apart)The same, queued to the outbox. It does not send: no email transport is configured.

Errors

An error carries a machine-readable code, a message, a link to the methodology and — where presenting a key or moving tier would have worked — a remedy saying so. The codes are unauthorized (401), forbidden (403), not_found (404), bad_request (400), rate_limited (429), not_implemented (501) and unavailable (503).

The envelope

Every successful response is the same envelope with the endpoint’s payload under data. The two keys worth reading before you integrate are tier — echoed so a stored response is self-describing — and notRecorded.

Envelope, rendered from the same function the routes uselive
{
  "apiVersion": "v1",
  "tier": "free",
  "asOf": "2026-10-11",
  "data": {
    "…": "the endpoint's own payload"
  },
  "notRecorded": [
    {
      "field": "brand.confidence",
      "reason": "Confidence is recorded on relationships, not on entities. See each item in `links[].confidence`."
    }
  ],
  "note": "Demonstration build: names, volumes and prices are synthetic and describe no real company, while the source registry, data model and lineage behaviour mirror the Traceleaf spec.",
  "editorialNote": "Traceleaf reports what the records show. An absent licence is an absence in the records, not a finding of illegality.",
  "docs": "https://traceleaf.io/methodology"
}

Why fields come back null

An absent field is returned as an explicit null with a reason, never synthesised and never silently omitted. A consumer has to be able to tell “we looked and there is nothing” from “we did not look” from “the value is zero”, and a provenance date is the field a law firm leans on hardest — so a plausible-looking fabricated one is the worst thing this API could ship. These are the reasons, verbatim from the contract module:

Reason codeWhat it says
ENTITY_CONFIDENCEConfidence is recorded on relationships, not on entities. See each item in `links[].confidence`.
FIRST_SEENNo observation date is recorded for this entity type. Only brands and relationships carry one.
LAST_SEENNo last-observed date is recorded for this entity type. Only relationships carry one.
SNAPSHOTNo stored copy of the source page exists. The source URL and the date Traceleaf last pulled it are returned instead.
SNAPSHOT_NO_URLNo stored copy exists because this source has no URL on record: it is held as a named register with nothing fetchable. Nothing was synthesised in its place.
SNAPSHOT_NOT_CAPTUREDThis source has a URL but no stored copy: the capture either failed or returned a short read, and a partial file is not served as evidence.
RECORD_URLNo source publishes a URL for an individual record, so `records[].url` is always null. Where the registry holds the register's homepage it is returned as `records[].registerUrl`, which is a link to a search portal and not a citation for this row.
SERIESNo series is published for this figure.
TIERED_CITATIONRegister names and URLs are returned to keyed callers on the team tier and above. The source's kind, jurisdiction and freshness are public so a figure can be assessed without them.

Three absences are structural rather than incidental, and the discovery document lists them first for that reason: confidence is recorded on relationships and on no entity; firstSeen exists on brands and relationships only; lastSeen on relationships only. The editorial line carried on every response is the same one the site holds to: “Traceleaf reports what the records show. An absent licence is an absence in the records, not a finding of illegality.”

Worked examples

Captured against the running API, trimmed only by dropping keys — every trim is marked in the block it appears in. The values are synthetic, per the note above; the shapes are not. Responses from a deployment with no Firestore service account say so in the payload, which is what the storage and transport blocks below are doing.

Search is the entry point for everything else: it takes a name, an alias, a hanzi name, a domain, a credit code or an id, and returns both the page URL and the API URL for each hit, so a client never has to construct a URL from a slug.

GET /api/v1/searchverbatim
$ curl "https://traceleaf.io/api/v1/search?q=ccell&limit=2"

{
  "apiVersion": "v1",
  "tier": "anonymous",
  "asOf": "2026-10-11",
  "data": {
    "query": "ccell",
    "items": [
      {
        "id": "B001",
        "kind": "brand",
        "name": "CCELL",
        "slug": "ccell",
        "href": "https://traceleaf.io/brands/ccell",
        "matched": { "field": "slug", "value": "ccell", "how": "exact" },
        "score": 250,
        "dataState": "collected",
        "profile": "https://traceleaf.io/api/v1/brands/B001"
      }
    ],
    "nextCursor": null,
    "total": 1
  },
  "notRecorded": [],
  "note": "Demonstration build: names, volumes and prices are synthetic and
           describe no real company, while the source registry, data model and
           lineage behaviour mirror the Traceleaf spec.",
  "editorialNote": "Traceleaf reports what the records show. An absent licence
                    is an absence in the records, not a finding of illegality.",
  "docs": "https://traceleaf.io/methodology"
}

The same records, at two tiers

This is the tier split in the only form that matters — the bytes. Both calls are the same URL with a different key.

Free keyverbatim
# Free key — the shape of the evidence, not the recipe.
#   data.records[0..1], from GET /api/v1/brands/B001

[
  { "kind": "ip",  "jurisdiction": "US",   "records": 3310,
    "lastPulledAt": "2026-10-12T03:36:18.502Z",
    "snapshotHeld": false, "snapshotCapturedAt": null },
  { "kind": "web", "jurisdiction": "INTL", "records": 4087,
    "lastPulledAt": "2026-10-12T11:36:18.502Z",
    "snapshotHeld": false, "snapshotCapturedAt": null }
]
Team keyverbatim
# Team key — the same two records, named and cited.
#   data.records[0..1], from GET /api/v1/brands/B001

[
  { "sourceId": "uspto", "register": "USPTO trademark search",
    "authority": null, "field": null, "basis": null,
    "url": null, "snapshot": null,
    "lastPulledAt": "2026-10-12T03:36:18.502Z" },
  { "sourceId": "whois", "register": "WHOIS history, Wayback, site fingerprints",
    "authority": null, "field": null, "basis": null,
    "url": null, "snapshot": null,
    "lastPulledAt": "2026-10-12T11:36:18.502Z" }
]

url and snapshot are null here, and the envelope says why: most of the 40 sources are held as a named register with no URL, so there is nothing to fetch and nothing to store. The known gaps section covers which.

Who is selling a brand, and where

GET /api/v1/sellersverbatim
$ curl -H "Authorization: Bearer $TRACELEAF_API_KEY" \
    "https://traceleaf.io/api/v1/sellers?brand=ccell&state=CA&limit=1"

# data only; the envelope keys are the same as above. Each item also carries
# kind, slug, confidence, firstSeen, lastSeen, records, type, location, brands.
{
  "filters": { "brand": "ccell", "state": "CA" },
  "total": 1,
  "items": [
    {
      "id": "S200",
      "name": "Zen Mart #200",
      "href": "https://traceleaf.io/sellers/seller-200",
      "state": "CA",
      "licence": {
        "numberOnFile": false,
        "number": null,
        "verdict": "no licence number found in CA's list as of the date checked"
      },
      "enforcement": ["fined"],
      "published": {
        "indexed": true,
        "reason": "1 enforcement action on record against Zen Mart #200 —
                   specific, consequential, and not in the state's licence lookup."
      }
    }
  ]
}

Note the shape of licence. It is not a verdict about a business: it reports whether a licence number is on file in that state’s list and the date it was checked, and the consumer draws its own inference, exactly as it would from the licence list itself.

Walk the chain

GET /api/v1/graphverbatim
$ curl -H "Authorization: Bearer $TRACELEAF_API_KEY" \
    "https://traceleaf.io/api/v1/graph/B001?depth=1"

# data only, with nodes[0..1] and edges[0..0] of the 28 nodes and 27 edges.
{
  "root": { "id": "B001", "kind": "brand", "name": "CCELL" },
  "depth": 1,
  "counts": { "nodes": 28, "edges": 27 },
  "nodes": [
    { "id": "B001", "kind": "brand", "name": "CCELL", "hops": 0,
      "href": "https://traceleaf.io/brands/ccell",
      "profile": "https://traceleaf.io/api/v1/brands/B001" },
    { "id": "S141", "kind": "seller", "name": "Cloud Corner #141", "hops": 1,
      "href": null, "profile": null }
  ],
  "edges": [
    {
      "id": "R0354",
      "kind": "is-sold-by",
      "confidence": "confirmed",
      "direction": "out",
      "from": { "id": "B001", "name": "CCELL", "kind": "brand" },
      "to":   { "id": "S009", "name": "Smoke Depot #9", "kind": "seller" },
      "firstSeen": "2023-04-01",
      "lastSeen": "2026-10-21",
      "unitsPerYear": null,
      "inferenceNote": "Observed on the storefront listing or in a review photo.",
      "records": [ /* two register rows, as above */ ]
    }
  ]
}

What changed

GET /api/v1/changesverbatim
$ curl -H "Authorization: Bearer $TRACELEAF_API_KEY" \
    "https://traceleaf.io/api/v1/changes?since=2026-09-01&limit=2"

# data only. Captured against a deployment with no Firestore service account,
# which is why the log is empty and says so rather than returning [].
{
  "since": "2026-09-01T00:00:00.000Z",
  "basis": "A snapshot-and-diff over the entity graph. Each run content-hashes
            every entity and relationship and compares it against the previous
            run's snapshot. `observedAt` is when Traceleaf observed the row in
            this state, not when the underlying change happened — no source
            publishes that. The first run of the log is a baseline and emits no
            records.",
  "history": {
    "runs": 0,
    "latestRunId": null,
    "empty": true,
    "note": "The change log holds no runs. Nothing has been recorded yet, so an
             empty list here is not a statement that nothing changed."
  },
  "storage": {
    "backend": "disk",
    "durable": false,
    "note": "No FIREBASE_SERVICE_ACCOUNT is configured, so the change log is
             held in a gitignored directory on the machine that wrote it."
  },
  "counts": { "appeared": 0, "changed": 0, "disappeared": 0 },
  "items": []
}

Watch an entity

The one write endpoint. It persists, matches and composes; it does not deliver, and the response names every stage between a change and an inbox rather than reporting one boolean.

POST /api/v1/watchlistsverbatim
$ curl -X POST -H "Authorization: Bearer $TRACELEAF_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"label":"CCELL watch","entityIds":["B001"],"email":"analyst@example.com"}' \
    "https://traceleaf.io/api/v1/watchlists"

# data only; pipeline.matching, .composition and .queue carry their own detail
# strings and are omitted here for length.
{
  "id": "b12d76d1-57b4-4f1e-8e97-4df368bd0936",
  "label": "CCELL watch",
  "createdAt": "2026-10-12T19:36:19.609Z",
  "entities": [ { "id": "B001", "name": "CCELL", "kind": "brand" } ],
  "storage": {
    "durable": false,
    "backend": "memory",
    "note": "No FIREBASE_SERVICE_ACCOUNT is configured, so writes are held in
             process memory and are lost on restart or redeploy."
  },
  "delivery": {
    "active": false,
    "reason": "Nothing will arrive from this watchlist because the change log is
               not readable and no email transport is configured.",
    "wouldFire": false,
    "recipient": "analyst@example.com",
    "pipeline": {
      "changeLog":   { "ready": false, "detail": "…" },
      "matching":    { "ready": true,  "detail": "…" },
      "composition": { "ready": true,  "detail": "…" },
      "queue":       { "ready": true,  "detail": "…" },
      "transport":   { "ready": false, "detail": "No email transport is
                        configured in this deployment. The message is composed
                        and queued; nothing has been sent." }
    }
  }
}

Preview the mail before it goes

A dry run is a first-class endpoint rather than a flag, because the question an operator and a buyer both have about an alert system is “show me the message before it is sent”, and that should not require a write. POST composes and files to an outbox; it sends nothing, and says so in those words.

GET /api/v1/alertsverbatim
$ curl -H "Authorization: Bearer $TRACELEAF_API_KEY" \
    "https://traceleaf.io/api/v1/alerts"

# data only; data.watchlists[] holds the composed messages themselves.
{
  "window": { "since": "2026-10-05", "until": "2026-10-12" },
  "dryRun": true,
  "basis": "observation-window",
  "basisStatement": "No Firestore service account is configured, so there is no
                     change log to read. Findings below are derived from
                     relationship observation windows, which is a dated
                     observation, not a diff.",
  "recordsRead": 0,
  "totals": { "composed": 0, "queued": 0, "wouldQueue": 0, "skipped": 0 },
  "transport": "No email transport is configured in this deployment. The message
                is composed and queued; nothing has been sent.",
  "wrote": { "outbox": false, "note": "Dry run. Nothing was written." }
}

MCP server

Traceleaf as a tool inside Claude, ChatGPT and any other MCP client. It is a thin wrapper over the same REST API — one HTTP call per tool, no second data path and no local cache — so anything a tool returns is a response you could have curled yourself.

It ships as source in mcp-server/ and is deliberately outside the web app, so it can be versioned and run by a buyer without a Traceleaf deploy. Without a key it runs at the anonymous tier: search and the stat tools work, and the rest return a 401 explaining what is needed.

Build and runshell
$ cd mcp-server
$ npm install && npm run build
$ TRACELEAF_API_KEY=tl_live_… \
  TRACELEAF_API_URL=https://traceleaf.io \
  node dist/index.js

Then point a client at it. Any client that launches a local command — Claude Desktop, Claude Code, and the editors that follow the same configuration shape — can use it as it stands, because the server speaks MCP over stdio:

claude_desktop_config.jsonjson
{
  "mcpServers": {
    "traceleaf": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-server/dist/index.js"],
      "env": {
        "TRACELEAF_API_KEY": "tl_live_…",
        "TRACELEAF_API_URL": "https://traceleaf.io"
      }
    }
  }
}

ChatGPT’s connectors expect a remote MCP endpoint over HTTP, which this server does not expose; reaching it from there needs a stdio-to-HTTP bridge in front, and nothing in this repo deploys one. Said plainly rather than left for an evaluation to discover.

ToolTierWhat it does
search_traceleafanonymousResolve a name, hanzi name, credit code, domain or id to an entity.
get_statanonymousOne published figure with its series and its method.
list_statsanonymousEvery published figure and the question each one answers.
get_profilefreeA full entity profile with the records behind each claim.
trace_supply_chainfreeWalk the chain outward from an entity, with per-edge confidence.
whats_newteamRows observed to have changed since a date.

Each tool description is long on purpose, and every tool returns the whole envelope rather than unwrapping data. A model cannot caveat what it was not told, and these answers end up in briefs and filings: that confidence is null on entities, that a placeholder brand’s null licence verdict means unchecked rather than unlicensed, and that the changes tool reports dated observations rather than a mutation log, all have to survive the trip into a model’s context.

Bulk exports

8 whole tables, free, no key, as CSV or JSON. For an analyst who wants everything this is better than the API, and for a retrieval agent a table parses far more reliably than a paragraph. The synthetic-data note travels inside the bytes: CSV carries it as # comment lines, JSON as a note field.

TableOne row isDownload
statsOne published Traceleaf figure, with its canonical wording and basis.CSV · JSON
brandsOne tracked cannabis vape brand and its licence, trademark and chain status.CSV · JSON
factoriesOne Chinese vape hardware factory and its registry, capacity and licensing record.CSV · JSON
exportersOne exporter of record, its linked factory and its 12-month shipment total.CSV · JSON
importersOne US importer of record, its ports, volumes and registration status.CSV · JSON
sellersOne retail or wholesale seller, its licence status and the brands on its shelves.CSV · JSON
hardwareOne hardware model, the brands that ship it and the factories inferred behind it.CSV · JSON
pricesOne price observation: step, month, state and USD value.CSV · JSON

The columns are the records. There is no isLegal, no isScam and no score — a seller row says whether a licence number is on file and the date it was checked, and nothing more.

Other surfaces

Three things built on the same data are worth knowing about before you build them yourself.

One-page briefs

/brief/brand/{slug}, /brief/factory/{slug} and /brief/importer/{slug} render one finding, one chart, the chain and the records behind it, inside a page box that prints to a single sheet. Every brand, factory and importer profile links to its own; see the brief for CCELL. They are noindex deliberately: a brief is a duplicate view of a profile that is already indexed on its merits, and one kind names a US importer whose profile is withheld pending legal review.

Evidence packs

/evidence and /evidence/{id-or-slug} produce a timestamped document for one entity: every link with its confidence and its inference note verbatim, every register behind each claim, and the stored copy of each register’s own source file with its hash and capture time. A pack prints the full citations, which is exactly what the API reserves for the team tier — so the pages sit behind the shared operator credential, the same one that opens the console. Following either link without a session lands you on the login page with your destination preserved.

The changes feed

/api/v1/changes reads a snapshot-and-diff log over the entity graph: each ingest run content-hashes every entity and relationship and compares it against the previous run. There is no page for it — it is a feed, and the honest reading of it needs the history and storage blocks that travel with it, which is why the response carries both rather than an array on its own.

Crawlers and answer engines

Retrieval agents are welcome on everything public here, and /llms.txt states the crawling and training policy in full. In robots.txt the keyless part of this API is allowed — /api/v1 itself, the discovery document, and /api/v1/stats/ — alongside the bulk exports at /api/data/. The keyed endpoints stay disallowed, not out of secrecy but because a crawler cannot authenticate: every fetch would spend crawl budget to collect a 401.

Demonstration build: names, volumes and prices are synthetic and describe no real company, while the source registry, data model and lineage behaviour mirror the Traceleaf spec.