Now live across the AI ecosystem: ChatGPT GPT Store · MCP Registry · mcp.so

Developers

The TofuBofu API

Your AI visibility data, wherever you want it. Eight endpoints, one bearer key, JSON.

Read every scan, pull a compact snapshot for a dashboard, fetch the llms.txt we generate for your site, or start a scan from your own code. Available on every plan, including the free one.

Sixty seconds to your first call

Nothing to install. If you have curl, you have everything.

1

Create a key

In the app, go to Settings and find the API and Webhooks card. Name a key and create it. The key is shown once, because we store only its sha256 hash and genuinely cannot show it to you again.

2

Send it as a bearer token

Every request carries Authorization: Bearer tb_live_… . There is no other auth scheme, no query-parameter key, and no session cookie.

3

Check it works

Call GET /api/v1/ping. It authenticates, touches no data and costs nothing, so it is the right thing to develop against.

export TOFUBOFU_KEY=tb_live_...

curl -s https://tofubofu.com/api/v1/ping \
  -H "Authorization: Bearer $TOFUBOFU_KEY"

A working key answers {"ok": true, "workspace_id": "…"}. Anything else is an error with a code you can branch on, listed further down.

Authentication, plans and limits

Auth

Bearer key, one per workspace member with owner rights. Keys start tb_live_, are stored hashed, and revoke instantly.

Plan access

Every plan, free included. There is no API tier and no API price. Starting a scan spends the same monthly quota as the button in the app.

Rate limits

60 requests a minute per workspace, and 5 a minute for starting scans. Over the limit you get HTTP 429 with the error code rate_limited and how long to wait. These are real ceilings, enforced in the API, not a policy we hope you respect.

Scope

A key sees its own workspace and nothing else. Ask for a report belonging to someone else and you get the same 404 as a report that does not exist.

Endpoints

All six, with a real request and a real response shape. Replace $TOFUBOFU_KEY with your key.

GET /api/v1/ping

Check a key works

Confirms the key is live and tells you which workspace it belongs to. The first call to make when something is not working.

Request

curl -s https://tofubofu.com/api/v1/ping \
  -H "Authorization: Bearer $TOFUBOFU_KEY"

Response

{
  "ok": true,
  "workspace_id": "1e1cd597c306"
}
POST /api/v1/crawler-hits

Report AI crawler visits from your own logs

The half of AI attribution no analytics tool can reach. GPTBot, ClaudeBot and PerplexityBot do not run JavaScript, so GA4 never sees them and neither does our tag. Post aggregated counts from your server logs. Group by bot, path and day; do not send one call per request. Unrecognised user agents are dropped rather than stored. A crawl means the engine read the page, not that it cited you.

Request

curl -s -X POST https://tofubofu.com/api/v1/crawler-hits \
  -H "Authorization: Bearer $TOFUBOFU_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hits":[{"bot":"PerplexityBot","path":"/pricing","day":"2026-08-14","hits":12}]}'

Response

{
  "ok": true,
  "accepted": 1,
  "dropped": 0
}
GET /api/v1/crawler-hits

What the AI crawlers have read

Totals by bot and by engine, the pages read most, and how many hits came from the crawlers that fetch a page to answer a live question rather than to train. User agents are self-declared and not verified by reverse DNS, and the response says so.

FieldInWhat it does
daysqueryHow far back, 1 to 365. Defaults to 30.

Request

curl -s "https://tofubofu.com/api/v1/crawler-hits?days=30" \
  -H "Authorization: Bearer $TOFUBOFU_KEY"

Response

{
  "days": 30,
  "total_hits": 412,
  "live_fetches": 37,
  "by_bot": [
    {"bot": "PerplexityBot", "hits": 210, "engine": "perplexity",
     "what": "Perplexity, builds its own index"}
  ],
  "by_engine": {"perplexity": 210, "chatgpt": 158},
  "top_pages": [{"path": "/services", "hits": 96}],
  "caveat": "User agents are self-declared and are not verified by reverse DNS..."
}
GET /api/v1/reports

List your scans

Every scan in the workspace, newest first, across all your brands. Use the report_id from here with the next endpoint.

Request

curl -s https://tofubofu.com/api/v1/reports \
  -H "Authorization: Bearer $TOFUBOFU_KEY"

Response

{
  "reports": [
    {
      "report_id": "3ee94d63",
      "brand_name": "Acme MSP",
      "domain": "acmemsp.com",
      "status": "completed",
      "created_at": "2026-08-11 09:14:02"
    }
  ]
}
GET /api/v1/reports/{report_id}

Read one scan in full

The complete stored report: every question asked, every engine's answer, the competitors each engine named, the site audit and the recommended fixes. This is the whole object the app renders from, so it is large.

FieldInWhat it does
report_idpathThe id from GET /api/v1/reports. Must belong to your workspace.

Request

curl -s https://tofubofu.com/api/v1/reports/3ee94d63 \
  -H "Authorization: Bearer $TOFUBOFU_KEY"

Response

{
  "brand": {"name": "Acme MSP", "domain": "acmemsp.com"},
  "mention_rate": 12.5,
  "share_of_voice": 4.1,
  "query_results": [
    {
      "query": "best managed IT services in Vancouver",
      "intent": "category_bofu",
      "results": {"chatgpt": "absent", "claude": "absent", "perplexity": "brief"}
    }
  ],
  "competitor_threats": [{"name": "Compunet", "mention_count": 9}],
  "action_items": [{"title": "Publish a comparison page against Compunet"}]
}

Truncated. The real response carries the full answer text per engine and every scored question.

GET /api/v1/summary

The latest scan, compact

A small, stable snapshot of the most recent completed scan for your active brand. Built for dashboards and widgets, so it will not make you parse the full report. The WordPress plugin and the Chrome extension both read this one.

Request

curl -s https://tofubofu.com/api/v1/summary \
  -H "Authorization: Bearer $TOFUBOFU_KEY"

Response

{
  "status": "completed",
  "report_id": "3ee94d63",
  "brand": "Acme MSP",
  "domain": "acmemsp.com",
  "visibility_index": 12.5,
  "mention_rate_pct": 12.5,
  "share_of_voice_pct": 4.1,
  "sentiment_score": 78,
  "top_competitors": [{"name": "Compunet", "mentions": 9}],
  "top_fixes": ["Publish a comparison page against Compunet"],
  "report_url": "https://tofubofu.com/report/3ee94d63"
}

Before your first completed scan this returns {"status": "none"} with HTTP 200, not an error.

GET /api/v1/llms-txt

Your generated llms.txt

The llms.txt file we build for your site from the crawl in your latest scan, in llmstxt.org format, as text/plain. Serve it at your own web root. This is the one thing the WordPress publisher cannot do for you, which is why the plugin fetches it here.

Request

curl -s https://tofubofu.com/api/v1/llms-txt \
  -H "Authorization: Bearer $TOFUBOFU_KEY"

Response (text/plain)

# Acme MSP

> Managed IT services for clinics and law firms across British Columbia.

## Services
- [Managed IT](https://acmemsp.com/managed-it): 24/7 monitoring and helpdesk
- [Cybersecurity](https://acmemsp.com/security): SOC 2 readiness and EDR
POST /api/v1/scans

Start a scan

Runs a scan on your own plan, against a brand that already exists in your workspace. With an empty body it re-scans your active brand, which is what an automation usually wants. It returns immediately with a report_id; the scan itself takes a few minutes.

FieldInWhat it does
domainbodyTarget an existing brand by domain. Optional.
brand_idbodyTarget an existing brand by id. Optional.
brand_namebodyOverride the brand name on the scan. Optional.
industrybodyUpdate the brand's industry before scanning. Optional.
descriptionbodyUpdate the brand's description. Optional.
geo_scopebodyWhere do you sell? Getting this wrong skews the whole report: a national firm scored on one city's questions looks invisible, and a local one scored nationally looks unwinnable. One of: global (Anywhere); national (Across one country); national_local (National, with a local angle); local (My own city or region). Shapes every buying question. This is the single highest-leverage answer here. Omit it rather than guessing: an omitted answer is recorded as unknown, a guessed one is indistinguishable from a real answer.
locationsbodyWhich places? The country you sell across, or the cities you are bound to. Shapes which geography goes into a localized buying question. Omit it rather than guessing: an omitted answer is recorded as unknown, a guessed one is indistinguishable from a real answer.
competitorsbodyWho would a buyer otherwise pick? Companies the user says a buyer would pick instead of them. We track whoever the engines name either way, so this is the user's own view rather than the whole comparison set. Shapes the comparison set, the mismatch between who you name and who the engines do, and which of those two a given finding is about. Omit it rather than guessing: an omitted answer is recorded as unknown, a guessed one is indistinguishable from a real answer.
buyer_questionsbodyQuestions your buyers ask. Questions this company's buyers actually ask. They are put to the engines verbatim and tracked scan over scan, which is what makes a trend line mean anything. Shapes the questions themselves, pinned ahead of the generated ones. Omit it rather than guessing: an omitted answer is recorded as unknown, a guessed one is indistinguishable from a real answer.
capacitybodyWho will do the fixes? We size and sequence the plan to match. A solo founder does not get a list built for a five-person team. One of: solo (Just me); one_marketer (One marketer); small_team (A small team, 2 to 4); full_team (A full team, 5 or more). Shapes the fix plan, which is generated in the tail after the engines answer. Omit it rather than guessing: an omitted answer is recorded as unknown, a guessed one is indistinguishable from a real answer.

Request

curl -s -X POST https://tofubofu.com/api/v1/scans \
  -H "Authorization: Bearer $TOFUBOFU_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain": "acmemsp.com"}'

Response

{
  "report_id": "9f2c81ab",
  "brand_id": "b41d0c",
  "status": "running",
  "report_url": "https://tofubofu.com/report/9f2c81ab",
  "scans_remaining": 3
}

The API never creates a brand. Add a brand in the app first, then automate its re-scans from here. This is deliberate: creating one per domain would hand a caller a fresh scan quota on every loop.

Errors

Every error carries a stable error code to branch on and a message for a human. detail repeats the message, for clients written before the codes existed.

{
  "error": "invalid_key",
  "message": "Invalid or revoked API key.",
  "detail": "Invalid or revoked API key."
}
StatusCodeWhen
401missing_keyNo Authorization header, or it is not a Bearer token.
401malformed_keyThe token does not look like a TofuBofu key. Keys start with tb_live_.
401invalid_keyThe key is unknown or has been revoked. We return one code for both on purpose: whether a key once existed is not something an unauthenticated caller should learn.
402quota_exceededThis brand has used its scans for the month on your plan. The message names the limit and the plan.
400too_manyMore than 5000 aggregated crawler rows in one call. Group by bot, path and day before sending, and split large batches.
404brand_not_foundNo such brand in this workspace. Add it in the app first.
404report_not_foundNo such report in this workspace, or it has not finished.
404no_llms_txtNo completed scan yet, so there is nothing to generate from.
404workspace_not_foundThe key resolved to a workspace that no longer exists.
429rate_limitedOver the documented rate limit. The message says how long to wait.

Things already built on this

You are not the first caller. These use the same key and the same endpoints, so anything they can do, your code can do.

WordPress plugin

Reads /summary for the dashboard widget and /llms-txt to serve a real llms.txt at your site root.

Chrome extension

Reads /summary to show the current Index in the popup.

Zapier and Make

Start a scan with POST /scans, then act on the scan-completed webhook.

The scan-completed webhook

Set it in Settings. We POST a signed payload when a scan finishes, so you can stop polling.

Connecting a CMS rather than writing code? That is Integrations.

Developer questions

Do I need a paid plan to use the API?

No. Any workspace can create a key, on any plan including the free Track tier. There is no separate API price and no API-only gate. Starting a scan over the API spends the same monthly scan quota as clicking the button in the app, so the free plan gets one a month through the API exactly as it does in the product. We left it ungated because the quota already does the work a gate would do.

Is the API read-only?

Mostly. Six of the eight endpoints only read. The two writes are POST /api/v1/crawler-hits and POST /api/v1/scans. Starting a scan spends your own plan's quota against a brand that already exists in your workspace, and reporting crawler hits writes only aggregated counts from your own server logs. There is no endpoint that deletes anything, edits a report, or creates a brand.

What are the rate limits?

60 requests a minute per workspace, and 5 a minute for POST /api/v1/scans. Going over returns HTTP 429 with the error code rate_limited and how long to wait. These are enforced in the API, not a stated policy: we would rather publish a real ceiling than promise nothing and find out what it is in production.

How do I get a key, and what happens if it leaks?

Settings, then the API and Webhooks card: name a key, create it, and copy it. The plaintext is shown exactly once and we only store its sha256 hash, so we cannot show it to you again and neither can anyone who reads our database. If a key leaks, revoke it in the same place. Revocation is immediate: the next request with that key gets a 401 with the code invalid_key.

Which brand does an endpoint use when I have several?

GET /api/v1/reports spans every brand in the workspace. GET /api/v1/summary and GET /api/v1/llms-txt resolve your active brand, which is the one with the most scans. POST /api/v1/scans takes a domain or brand_id so you can be explicit, and falls back to the active brand when you send an empty body. If you run several brands, be explicit.

How do I know when a scan has finished?

Two ways. Poll GET /api/v1/reports and watch the status field turn from running to completed, or set the scan-completed webhook in Settings and we will POST to you instead. The webhook is signed, so you can verify it came from us. Polling once a minute is well inside the rate limit.

Is there a sandbox or test mode?

No, and there is no test key prefix. Every key is live and every scan spends real quota against real AI engines. GET /api/v1/ping is the safe call to develop against: it authenticates, touches no data and costs nothing.

Do you have SDKs?

Not yet. Eight endpoints, bearer auth and JSON do not need a client library, so the examples here are curl and the answer is the same in any language. If you would use an SDK, say so and we will build one.

You need a scan before you have data to pull

Free scan first. No credit card. Then create a key in Settings.

Run my free scan →