API documentation
Read your projects and scan history, trigger scans, manage prompts and webhooks, attribute AI referrals from your own site, and upload server access logs over REST or the MCP server. Every request is authenticated with a personal access token you create in the dashboard.
https://llmmetrix.comAuthentication
Every endpoint takes a bearer token in the Authorization header. Tokens are prefixed llmx_; anything without that prefix is rejected before a database lookup happens.
curl https://llmmetrix.com/api/v1/projects \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx"Creating a key
- Read keys are created in the dashboard under Account → API keys. Beacon (collector) keys are minted from the AI Traffic page; see Installing the beacon.
- The raw token is shown exactly once, at creation. It is not retrievable afterwards and support cannot recover it. Copy it into your secret store immediately. If you lose it, revoke the key and create another.
- Only the SHA-256 hash of the token is stored. Authentication hashes the presented token and looks up that hash, so a database dump contains no usable credentials. Alongside it we keep the first 12 characters as a display prefix (e.g.
llmx_ab12cd3) so you can tell your keys apart in the UI, plus alast_used_attimestamp updated on each authenticated call. - Keys are long-lived by default but can carry an optional expiry: pass
expires_at(an ISO datetime in the future) at creation and the key authenticates as nothing once past it. Revoke any key at any time from the same screen; revocation takes effect on the next request. - API + MCP access requires a paid plan (Business or Agency). Keys can also be scoped by capability: pass
scopes: ["read"]for a monitoring credential that cannot trigger scans, or["write"]for a pipeline credential that cannot list. The default is both. Rate limits scale with your plan (see Rate limits).
The two key kinds, and why the split exists
Every key carries a kind. The two are not interchangeable, and the separation is the single most important property of this API's security model.
| Kind | Can do | Cannot do | Where it lives |
|---|---|---|---|
api | Read: every GET /api/v1 endpoint. Write: POST /api/v1/scans (trigger a scan), retry jobs, manage tracked prompts and the webhook URL, POST /api/ingest/logs. Also drives the MCP server. | Rejected by POST /api/collect. | Your server, your secret manager, your CI. Never in a browser. |
collector | Append AI-referral events via POST /api/collect, for the one project it is bound to. | Rejected by every /api/v1 route (read AND write) and by the MCP server. Treated exactly like an unknown key. | Your public page source. It is publishable by construction. |
Why this matters
A first-party analytics tag has to carry a credential in the page source, which means anyone who views source has it. The usual outcome is that a single API credential ends up both published and capable of reading tenant data. We split the credential instead:
- The shared authentication helper for the read API rejects a
collectorkey beforeit returns a session. The key resolves to the same “invalid key” result as a random string. So the credential in your page source cannot be turned around and used to read your scans, projects or competitors. - Conversely the beacon rejects a full
apikey. You cannot accidentally paste a read key into your site template and have it work. It returns401, which is the failure mode you want. - A
collectorkey must be bound to exactly one project at creation; an unscoped or multi-project collector key is refused with400. The beacon then derives its target project from the key's own binding and never from the request body, so a scraped key cannot be pointed at another project or another tenant. - Both write endpoints re-check, on every request, that the key's owner is still a member of the project's workspace. Removing someone from a workspace kills their keys' access immediately, rather than at the next key rotation.
Project scoping
A key can be scoped to a list of projects at creation (up to 100 project UUIDs; every one is validated against the creator's own workspace memberships, so a key can never name a project its creator cannot reach). An unscoped key sees every project in every workspace its owner belongs to.
GET /api/v1/projectsintersects your memberships with the key's scope.GET /api/v1/scansreturns403 project not in key scopewhen a scoped key asks for a project outside its scope.POST /api/ingest/logsreturns403 project not in key scopefor an out-of-scope project, a project that does not exist, or a project whose workspace you have since left.- The write endpoints inherit the same boundary: triggering a scan, listing or retrying jobs, and managing tracked prompts all resolve the target project through the same membership ∩ scope allowlist. An out-of-scope target reads as
404(trigger, retry, prompts) or an empty list (jobs, prompts), never as another tenant's data.
Rate limits
| Surface | Limit | Bucket | Response |
|---|---|---|---|
GET /api/v1/projectsGET /api/v1/scansGET /api/v1/jobsGET /api/v1/jobs/[jobId]GET /api/v1/promptsGET /api/v1/webhooksMCP tools | 120 requests / minute on Business · 600 / minute on Agency | Per key: every read endpoint AND the MCP tools share one counter. | 429 with a Retry-After header (seconds). |
POST /api/v1/scansPOST /api/v1/jobs/[jobId]/retryPOST /api/v1/promptsPATCH /api/v1/prompts/[id]DELETE /api/v1/prompts/[id]PUT/DELETE /api/v1/webhooks | 60 requests / minute on Business · 300 / minute on Agency | Per key: all write endpoints share one counter, separate from reads. | 429 with a Retry-After header (seconds). |
POST /api/ingest/logs | 10 uploads / minute | Per key. | 429 {"error":"Rate limit exceeded (10 uploads/minute)"} with a Retry-After header (seconds). |
POST /api/collect | 240 requests / minute | Per project: one misbehaving tag install cannot exhaust the shared write path. | 429 {"error":"Rate limit exceeded"} with a Retry-After header (seconds). |
Windows are sliding, one minute wide, and every 429 carries a Retry-After header in seconds; the callers here are scripts with no human to read a JSON message and back off. Two layers sit underneath the write limits and are documented on the endpoints they gate: triggering a scan runs the full dashboard gate ladder (cooldown, budget gates, per-user rate and concurrency caps), and retrying a job is throttled at 5 restarts / 10 minutes per user.
Errors
Errors are JSON with an error field. A 500 additionally carries a short ref correlation code that is also written to our server logs. Quote it in a support ticket and we can find the exact failure.
| Status | Meaning |
|---|---|
200 | Success. On /api/ingest/logs, 200 specifically means the upload was deduplicated onto an existing job. |
202 | Accepted: a scan was queued ( POST /api/v1/scans returns the jobId to poll), a job was restarted, or /api/ingest/logs queued a new parse job. |
400 | Malformed JSON, invalid parameters, or a body that failed schema validation. |
401 | Missing, malformed, unknown, or wrong-kind key. |
402 | A plan, budget or payment gate: prompt caps reached (trigger a scan, create/activate a prompt), a suspended workspace, or a capability your plan does not include (log ingest needs Agency). The beacon never 402s. A plan or origin rejection there answers 200 {"ok":true,"stored":false} indistinguishably, because the collector key is public by design. |
403 | Authenticated, but not permitted: project outside the key's scope, or an unverified domain. A beacon Origin that does not belong to the project's site is not 403. The collector answers 200 {ok:true,stored:false} so a public key cannot be probed by status code. |
404 | Not found, including jobs, scans or prompts the key cannot see, which are deliberately indistinguishable from missing ones. Also the collector key's bound project no longer exists. |
409 | Conflict: a job is not restartable, or its row changed state mid-flight. |
413 | Body over the size cap (8 KB for the beacon, 4 MB for log ingest). |
429 | Rate limited. See above. |
500 | Server error. Body is {"error":"…","ref":"a1b2c3d4"}. |
503 | A dependency is unavailable or not configured (the collector's database, or log storage). |
/api/v1/projects
Lists the projects visible to the key: every project in every workspace the key's owner belongs to, intersected with the key's project scope. Newest first.
Takes no query parameters and is not paginated: the full list is returned in one response, ordered by created_at descending.
curl https://llmmetrix.com/api/v1/projects \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx"{
"data": [
{
"id": "8f14e45f-ceea-467a-9cbe-3e2f5d1b7a90",
"domain": "acme.com",
"brand_name": "Acme",
"industry": "B2B SaaS",
"competitors": ["globex.com", "initech.com"],
"engines": ["chatgpt", "perplexity", "gemini", "claude"],
"created_at": "2026-04-02T09:14:22.881Z",
"workspace_id": "1d2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b"
}
]
}If the key's owner belongs to no workspace, the response is {"data": []} rather than an error.
/api/v1/scans
Returns completed visibility scans with their per-engine aggregates and per-answer detail, newest first.
| Parameter | Type | Default | Notes |
|---|---|---|---|
project_id | UUID | none | Optional. Restricts results to one project. Omit it to get scans across every project the key can reach. |
limit | integer | 20 | Clamped to 1–100. Values outside the range are clamped, not rejected; a non-numeric value falls back to 20. |
offset | integer | 0 | 0-based page offset. Use with limit to page through history. |
What this endpoint deliberately excludes
- Only
status: "completed"scans are returned; in-flight and failed scans never appear. - Only scans of your own brand in the project's default market. Competitor-subject benchmark scans and additional-region scans are stored on the same project but are filtered out here, because they carry no distinguishing marker in this response shape. Mixing them in would silently corrupt any dashboard that trends or sums these scores.
- Engines whose every call failed are omitted from
engine_results, and individual answers whose engine call errored are omitted fromprompt_results. An infrastructure failure reads as absent data, not as zero visibility.
Pagination is limit + offset; page through history a window at a time. The endpoint reads the most recent window of history by default.
curl "https://llmmetrix.com/api/v1/scans?project_id=8f14e45f-ceea-467a-9cbe-3e2f5d1b7a90&limit=5" \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx"{
"data": [
{
"id": "b6c1a2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"project_id": "8f14e45f-ceea-467a-9cbe-3e2f5d1b7a90",
"status": "completed",
"score": 62,
"summary": "Acme is cited in 6 of 10 answers, most often behind Globex.",
"created_at": "2026-07-29T07:03:11.402Z",
"engine_results": [
{
"engine": "chatgpt",
"label": "ChatGPT",
"score": 71,
"mentionRate": 0.8,
"avgRank": 2.1,
"sentiment": "positive",
"prompts": 10
}
],
"prompt_results": [
{
"engine": "chatgpt",
"label": "ChatGPT",
"prompt": "best invoicing software for agencies",
"mentioned": true,
"rank": 2,
"sentiment": "positive",
"competitorsMentioned": ["Globex"],
"citations": [
{
"url": "https://acme.com/pricing",
"domain": "acme.com",
"title": "Acme Pricing",
"position": 1
}
],
"answerExcerpt": "For agencies, Acme and Globex are the two…",
"answerText": "For agencies, Acme and Globex are the two most…",
"claimsAboutBrand": ["Acme offers unlimited seats"],
"accuracyRisk": "low"
}
]
}
]
}| Field | Notes |
|---|---|
score | 0–100 visibility score for the scan. Nullable. |
engine_results[].mentionRate | Fraction (0–1) of that engine's delivered answers mentioning the brand. |
engine_results[].avgRank | Average position within the answer, or null when the brand was never ranked. |
engine_results[].prompts | Number of answers actually delivered by that engine (errored calls are not counted, and are not billed). |
sentiment | One of "positive", "neutral", "negative". |
accuracyRisk | One of "none", "low", "medium", "high": the hallucination risk of the claims the answer makes about your brand. |
citations[].url | The real cited URL on search-grounded engines; null on ungrounded ones, where only the domain could be extracted from the answer prose. |
answerExcerpt | First 280 characters of the answer. answerText carries the full text. |
/api/v1/scans
Triggers a real visibility scan and returns the jobId to poll. 202 means queued, not completed.
Body: {"projectId":"…","competitor?":"…","region?":"…"}. The scan runs through the same gate ladder as the dashboard (cooldown, weekly/daily/monthly budget gates, payment suspension, per-user rate and concurrency caps), so a script cannot spend where the UI would refuse. Poll GET /api/v1/jobs/[jobId] until it reaches a terminal status, then read the answers via GET /api/v1/scans.
Gate notes
- A
402carries the gate that refused:{ error, plan, used, limit }for prompt caps, oraccount_suspendedfor a payment-suspended workspace. - A
429is the cooldown (60 s between scans of the same subject+market) or an in-flight scan; theRetry-Afterheader is a poll interval, not a deadline. 403 domain_blockedwhen the project's domain is on the acceptable-use blocklist.
curl -X POST "https://llmmetrix.com/api/v1/scans" \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"projectId":"8f14e45f-ceea-467a-9cbe-3e2f5d1b7a90"}'{
"jobId": "9f2f4e6d-5c4b-4a3b-9e8f-1a2b3c4d5e6f"
}/api/v1/jobs
Lists background jobs visible to the key, newest first.
Visibility is the same three-tier union as the dashboard's activity page: jobs of projects in scope, jobs of workspaces the owner belongs to, and the owner's own user-tier jobs (exports, notifications). Optional filters: status (one of pending, processing, completed, failed, dead, canceled), type (e.g. scan), project_id, limit (default 50, max 100) and offset.
curl "https://llmmetrix.com/api/v1/jobs?status=failed&limit=10" \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx"/api/v1/jobs/[jobId]
Returns one job: status, attempts, error and the scan coverage snapshot.
The poll endpoint for a triggered scan. The attempts/max_attempts pair answers the retryable-vs-give-up question. A job the key cannot see is 404, indistinguishable from a missing one. coverage carries the per-engine coverage snapshot for scan jobs (which engines returned nothing), never the full result payload.
curl "https://llmmetrix.com/api/v1/jobs/9f2f4e6d-5c4b-4a3b-9e8f-1a2b3c4d5e6f" \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx"{
"data": {
"id": "9f2f4e6d-5c4b-4a3b-9e8f-1a2b3c4d5e6f",
"type": "scan",
"status": "completed",
"error": null,
"attempts": 1,
"max_attempts": 5,
"project_id": "8f14e45f-ceea-467a-9cbe-3e2f5d1b7a90",
"created_at": "2026-08-11T07:00:00.000Z",
"updated_at": "2026-08-11T07:00:42.000Z",
"started_at": "2026-08-11T07:00:00.000Z",
"finished_at": "2026-08-11T07:00:42.000Z",
"coverage": { "engines": ["chatgpt", "perplexity"], "failedEngines": [] }
}
}/api/v1/jobs/[jobId]/retry
Re-drives a failed, dead or stale-processing job through the same restart ladder as the dashboard.
Restarts the same job row, so a re-run can never double-charge (the credit ledger is guarded on the job id). Gate notes: 409 when the job is not restartable or its row moved mid-flight; 402 for a suspended workspace or AI budget refusal; 403 for viewers. On top of the 60 writes/minute bucket, restarts are throttled at 5 / 10 minutes per user.
curl -X POST "https://llmmetrix.com/api/v1/jobs/9f2f4e6d-5c4b-4a3b-9e8f-1a2b3c4d5e6f/retry" \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx"{
"jobId": "9f2f4e6d-5c4b-4a3b-9e8f-1a2b3c4d5e6f",
"restarted": true,
"status": "pending"
}/api/v1/prompts?project_id=…
Lists a project's tracked prompts (active first, then newest).
project_idis required. An out-of-scope project reads as an empty list, never as another tenant's prompts. When a project has active tracked prompts, scans run exactly those instead of auto-generated discovery prompts.
/api/v1/prompts
Adds a tracked prompt, enforcing the per-plan active-prompt cap.
Body: {"projectId":"…","text":"…"}, text 1–500 chars. At the cap the response is 402 { error, plan, limit }; deactivate one prompt or upgrade. The count is enforced under an advisory lock, so concurrent adds cannot slip past it.
/api/v1/prompts/[id]
Activates or deactivates a tracked prompt.
Body: {"active": true | false}. Turning a prompt on is capped by the same per-plan active limit as creation; turning one off never is.
/api/v1/prompts/[id]
Deletes a tracked prompt.
Same role gate as the dashboard's delete: the key owner must hold owner, admin or memberin the prompt's workspace. Returns { ok: true }.
Webhooks
A webhook URL that receives scan.completeddeliveries on behalf of the key's owner, the same per-user setting as the dashboard's notification settings, delivered through the same queue and SSRF-guarded HTTP client. URLs must be https:// (http is rejected with a 400). { webhook_url: null } clears it.
/api/v1/webhooks
Returns the configured webhook URL (or null).
/api/v1/webhooks
Sets the webhook URL.
curl -X PUT "https://llmmetrix.com/api/v1/webhooks" \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"webhook_url":"https://hooks.example.com/scan-completed"}'/api/v1/webhooks
Clears the webhook URL.
MCP server (Model Context Protocol)
The public API speaks MCP over Streamable HTTP at https://llmmetrix.com/api/mcp. Point Claude Desktop, Cursor or any MCP client at it with the same Authorization: Bearer llmx_… header. Reads: list_projects, list_scans, get_scan (full answers for one scan), get_job, list_prompts, get_webhook. Writes: trigger_scan (returns a job_id to poll with get_job), create_prompt, set_prompt_active, delete_prompt, set_webhook, clear_webhook, retry_job.
Every tool is the identical REST request over a different transport: reads share one read bucket, writes one write bucket (per key, at your plan's rate tier; see Rate limits above), and per-tool access falls out of the same membership ∩ scope allowlist the REST routes use: a project-scoped key can only ever read or write projects in its own scope, the write actions run the same per-plan caps and payment gates as the dashboard, and a collector key is rejected like any unknown key. Serving is STATELESS (MCP revision 2026-07-28): every request stands alone (no handshake, no session id, no affinity), while 2025-era clients are still served through a stateless fallback. The openapi.json contract below documents the REST surface the tools mirror.
API + MCP access requires a paid plan (Business or Agency) and rides your key's capability scopes: a key minted with scopes: ["read"] sees only the six read tools, one with ["write"] only the seven write tools: an absent scope is an absent tool in tools/list, not a refused call. Keys can also carry an optional expires_at; an expired key authenticates as nothing.
Connecting from Claude Desktop
{
"mcpServers": {
"llmmetrix": {
"type": "http",
"url": "https://llmmetrix.com/api/mcp",
"headers": { "Authorization": "Bearer llmx_xxxxxxxxxxxx" }
}
}
}OpenAPI contract
The machine-readable contract lives at https://llmmetrix.com/openapi.json: OpenAPI 3.1, generated from the request schemas the API actually validates with (so the two cannot drift), with the response shapes this page documents. Use it to generate typed clients for your language of choice.
/api/collect
The public AI-referral beacon. Called from your own site on page load with a collector key; records visits that arrived from an AI assistant. Runs on the edge, accepts cross-origin requests, and returns no readable tenant data.
| Field | Type | Notes |
|---|---|---|
key | string ≤ 200 | The collector key. Sent in the body because navigator.sendBeacon cannot set headers; an Authorization: Bearer header also works and takes precedence. |
referrer | string ≤ 2048 | document.referrer. Defaults to empty. |
page | string ≤ 2048 | Landing page: a path ("/pricing") or a full URL. Only the path component is stored; query and hash are discarded because they can carry personal data. |
utm | object | source, medium, campaign, term, content. utm_source is the primary classification signal. |
sessionId | string ≤ 64 | Optional opaque session id. |
projectId | UUID | Advisory only. Validated if present, then ignored; the target project always comes from the key's own binding. |
The body is validated strictly: unknown fields are rejected. The raw body is capped at 8 KB before parsing.
curl -X POST https://llmmetrix.com/api/collect \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx" \
-H "Content-Type: text/plain" \
-d '{
"referrer": "https://chatgpt.com/",
"page": "/pricing",
"utm": { "source": "chatgpt", "medium": "ai_referral" }
}'// Classified as an AI referral and stored:
{ "ok": true, "stored": true, "source": "chatgpt", "matchedBy": "referrer" }
// Acknowledged but NOT an AI-attributed visit (deliberately not stored):
{ "ok": true, "stored": false }| Status | Body | Cause |
|---|---|---|
400 | {"error":"Invalid JSON body"} / {"error":"Invalid payload","details":…} | Unparseable body, or a field that failed validation. |
401 | {"error":"Missing or invalid write key"} | No key, an unknown key, a full api-kind key, a collector key with no bound project, or an owner who is no longer a member of the project's workspace. |
200 | {"ok":true,"stored":false} | Acknowledged, nothing stored. Returned for a visit that was not an AI referral, and (deliberately indistinguishably) when AI traffic attribution is not on the workspace's plan or the browser Origin does not fall under the project's domain. A collector key is public by design, so a distinct status for either would let anyone holding one read the workspace's plan tier or probe its bound domain. |
404 | {"error":"project not found"} | The key's bound project has been deleted. |
413 | {"error":"Payload too large"} | Body over 8 KB. |
429 | {"error":"Rate limit exceeded"} | Over 240 requests/minute for this project. Carries Retry-After. |
503 | {"error":"Collector not configured"} | The collector's backing store is unavailable on this deployment. |
What is stored, and what is not
- Stored: the matched AI source, the landing path, your
utm_*values, the optional session id, and a country code taken from the edge platform's own geo header, never from anything the caller sends. - Not stored: cookies (none are set), IP addresses, query strings, hashes, or any visit that did not classify as an AI referral.
- Classification:
utm_sourcewins whenever it matches a known AI source, because AI apps routinely strip the referrer; the referrer host is the fallback. The response tells you which decided it viamatchedBy.
Integrity, stated plainly
Because the collector key is published in your page source, anyone who reads it can append referral rows to your own project, never to anyone else's. The Origin check stops the key being embedded on an unrelated site and beaconed from a browser there, and the per-project rate limit bounds volume. A non-browser client that simply omits Origin is allowed through, because rejecting header-less requests would break the documented server-side sender path and an attacker omits the header just as easily. This residual is inherent to every first-party analytics tag.
Installing the beacon
You do not have to call /api/collect yourself. We ship a small tag that reads the referrer and utm_* parameters on page load and beacons them for you.
- Mint a collector key. Open Dashboard → AI Traffic, select the project, and click Generate write key. The key is bound to that one project and is shown once; the snippet on that page comes pre-filled with it.
- Paste the snippet into your site before
</body>, or into a Google Tag Manager Custom HTML tag firing on All Pages.Install snippet<script async src="https://llmmetrix.com/tag.js" data-project="8f14e45f-ceea-467a-9cbe-3e2f5d1b7a90" data-key="llmx_xxxxxxxxxxxx"></script> - Serve it from the domain on the project. The beacon checks the browser's
Originagainst the project's domain: the apex and any subdomain of it are accepted (acme.comcoversshop.acme.com, but not the other way round). A tag installed on an unrelated host is accepted with200 {"ok":true,"stored":false}, not403. - Tag your AI-facing links where you can. AI assistants frequently strip the referrer, so a
utm_sourceis the reliable signal. Recognised sources are ChatGPT, Perplexity, Gemini, Claude, Copilot and Meta AI; the AI Traffic page has a link generator that emits correctly-tagged URLs. - Verify. Load a page with
?utm_source=chatgptand confirm the beacon request returns{"ok":true,"stored":true}in your browser network panel. Referrals appear on the AI Traffic page.
| Attribute | Required | Notes |
|---|---|---|
data-project | Yes | Project UUID. The tag will not fire without it, though the server still derives the real target from the key. |
data-key | Yes | The collector key (llmx_…). |
data-endpoint | No | Override the collector URL. Defaults to /api/collect on the origin the script itself was loaded from. |
The tag sends text/plain so the POST stays a CORS simple request and needs no preflight; it uses navigator.sendBeacon where available and falls back to fetch(…, { keepalive: true }). It sets no cookies and sends no personal data: referrer, path and utm_* only.
/api/ingest/logs
Uploads a chunk of server access log for asynchronous parsing. Crawler hits from AI bots are extracted against our bot taxonomy and rolled up into the project's AI Traffic analytics.
Authenticated with a full api key (a collector key is rejected). The route does no parsing itself: it validates, stores the body, queues a job and returns a job id.
| Field | Type | Notes |
|---|---|---|
projectId | UUID, required | The project the log belongs to. |
format | "auto" | "combined" | "json" | Defaults to "auto". "combined" is NGINX/Apache combined format; "json" is line-delimited JSON (Vercel/CDN). |
content | string, required | The raw log text. 1 to 2,000,000 characters. |
Domain verification is required (tier 1+)
The project's domain must be verified to at least tier 1 (the meta_tag method: <meta name="llmmetrix-site-verification" content="…"> on the home page). Otherwise the upload is refused with 403 domain_not_verified.
This is not paperwork. Scanning a domain is observation of public data and is deliberately ungated; you can track a competitor from day one. Uploading access logs is a different claim: those rows become that project's crawler analytics, so accepting logs for a domain nobody has proven they control would be a data-poisoning primitive. Tier 1 is the bar because it demonstrates control of the site while taking five minutes and no DNS ticket. Request a token from POST /api/projects/{id}/verify, or use the Domains page in the dashboard.
curl -X POST https://llmmetrix.com/api/ingest/logs \
-H "Authorization: Bearer llmx_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg c "$(tail -n 5000 /var/log/nginx/access.log)" \
'{projectId:"8f14e45f-ceea-467a-9cbe-3e2f5d1b7a90", format:"combined", content:$c}')"// 202, a new parse job was queued:
{ "jobId": "3f9a1c7e-2b44-4d1a-9f0e-77c5b8a3e112", "deduped": false }
// 200, identical content was already uploaded; collapsed onto the existing job:
{ "jobId": null, "deduped": true }| Status | Body | Cause |
|---|---|---|
400 | {"error":"Invalid JSON body"} or the first validation message | Unparseable body, unknown field, missing projectId, empty or over-length content. |
401 | {"error":"Invalid or missing API key"} | No key, an unknown key, or a collector key. |
402 | {"error":"upgrade_required"} | Server-log ingestion requires the Agency plan. |
403 | {"error":"project not in key scope"} | Project outside the key's scope, non-existent, or in a workspace you have left. |
403 | {"error":"domain_not_verified"} | The project's domain is not verified to tier 1. |
413 | {"error":"Payload too large…"} | Raw request body over 4 MB. Split the upload. |
429 | {"error":"Rate limit exceeded (10 uploads/minute)"} | Over 10 uploads/minute for this key. |
503 | {"error":"storage_unavailable"} | The log could not be stored. Retry the whole upload. |
Two size limits, not one
content is capped at 2,000,000 characters by the schema, and the whole raw request body is capped at 4 MB: the JSON envelope plus escaping around that content. Rotate through your log in chunks and upload each one.
Uploads are idempotent on content: re-sending an identical chunk for the same project collapses onto the existing job instead of double-counting the traffic, so an interrupted batch script can simply be re-run. Uploaded logs go to private storage that no customer-facing credential can read.
Something missing?
These are the endpoints available to an API key today. If you need something that is not here (project creation, or a larger export), tell us what you are building.

