API documentation (v1.2)
Run audits programmatically and fetch the result as JSON. Each audit costs 1 credit — exactly as in the app.
Last updated: · Markdown version (for agents and AI assistants)
Authentication
Create a key on the API page (shown once; we store only its hash). Send it in the Authorization header of every request:
Authorization: Bearer co_live_xxxxxxxx...
Base URL: https://app.citationone.com. All endpoints live under /api/v1.
GET /api/v1/me
Quick key check — returns the account it belongs to and the key scopes.
curl https://app.citationone.com/api/v1/me \
-H "Authorization: Bearer co_live_..."
# 200 ->
{ "userId": "...", "email": "you@example.com", "role": "user",
"auditCredits": 20,
"apiKey": { "id": "...", "scopes": ["audits:read","audits:write"],
"webhookSecret": "9f2c..." }, // only with audits:write
"apiVersion": "v1.2" }Scopes decide what a key may do: audits:read — reading (GET /audits, GET /audits/{id}); audits:write — submitting audits and managing sharing (POST /audits, POST /audits/bulk, POST/DELETE /audits/{id}/share, POST /audits/bulk/{id}/cancel). A missing scope returns 403 insufficient_scope. Keys created in the panel get both, so existing integrations keep working unchanged.
POST /api/v1/audits — run an audit
Returns 202 immediately with a job id; the audit runs in the background (2–7 min). Then poll GET /api/v1/audits/{id}, or supply webhookUrl and skip polling entirely.
curl -X POST https://app.citationone.com/api/v1/audits \
-H "Authorization: Bearer co_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-unique-id-123" \
-d '{
"url": "https://example.com/article",
"keyword": "target keyword",
"language": "en",
"project": "Acme Store",
"share": false
}'
# 202 ->
{ "id": "job_V1StGXR8...", "status": "queued", "createdAt": "2026-06-12T10:00:00.000Z" }Parameters (JSON body):
| Field | Required | Description |
|---|---|---|
| url | conditional | Page to audit — we fetch and analyse its content. URL validation (private addresses blocked, SSRF). Supply EITHER `url` OR `content`; sending both returns 400, because we will not guess which material you meant to audit while charging you for it. |
| content | conditional | Content supplied directly instead of `url` (50–200,000 characters). For material that does not live at any address yet: a new site, a draft post, copy before publication. The audit runs identically to a fetched page — same SERP analysis, same dimensions, same cost of 1 credit. The difference: with no address there are no page HTML metrics, so technical dimensions rely on the text alone. |
| title | no | Title of the material — used only together with `content` (max 300 characters). Without an address there is nowhere to read it from, and it feeds CSI analysis and the dimensions. If omitted we substitute the keyword. |
| keyword | no | Target keyword or phrase (max 200 characters). **Must contain at least two words** — a single word carries no search intent (Google mixes unrelated meanings for it), so a one-word value returns 400 invalid_request. If omitted we attempt auto-detection; if that fails, error keyword_required. |
| language | no | Content language, ISO 639-1 (e.g. pl, en, de). If omitted, detected automatically. |
| project | no | Project the audit is assigned to (max 100 characters) — the same tag as "Project" in the audit list in the app. Projects are not separate entities: grouping is by EXACT name, so "Acme Store" and "acme store" are two different projects. An unknown name creates a new project (no error). Without this field the audit is unassigned. Must be a string: a number returns 400. |
| shareExpiresInDays | no | Integer 1–365 — after that many days the public link stops working. Requires share: true (otherwise 400: an expiry without a link is almost always a mistake in how the body was assembled). Without this field the link never expires and must be revoked manually. The clock starts when the link is ISSUED, i.e. when the audit finishes, not when you send the request. |
| webhookUrl | no | HTTPS address we will POST the completion event to — instead of polling in a loop. Validated when the request is accepted (a bad address is an immediate 400, not a silent failure minutes into a paid audit). Details and signature verification in the "Webhooks" section. |
| share | no | Boolean, defaults to false. true = once the audit finishes we issue a public read-only link to the report (meta.shareUrl) — opened without logging in, by anyone who knows the address. Without this field meta.shareUrl = null. Must be a boolean: the string "true" returns 400. |
The public report link (meta.shareUrl) is created only when you explicitly ask for it via "share": true — the example above uses false, the default, and the audit result is available only after logging into your account. To issue a link:
-d '{ "url": "https://example.com/article", "share": true }'A report shared this way opens for anyone who knows the address — no login. The report language follows the audited content: ?lang=pl for Polish content, ?lang=en otherwise. Revoke the link in the app (report → Share → revoke) or via DELETE /api/v1/audits/{id}/share. Reading the result never issues or restores a link — publishing is always an explicit action. A report computed earlier without this flag can be shared via POST /api/v1/audits/{id}/share at no cost.
Headers:
Idempotency-Key(optional) — the same request with the same key will not create a second audit or charge a second credit; it returns the existing job with200. The parameters of the first request are binding: a retry with the same key but a changedshare(or any other field) will not modify the job already created. If you want different parameters, use a different key.X-Request-Id(optional) — if you send one, we echo it back on every response and use it in our logs, which stitches your trace to ours. If you do not, we generate one. See "Limits and credits".
GET /api/v1/audits/{id} — status / result
Poll every ~15–30 s. status moves through: queued → running → completed (or error). {id} is normally a job id (job_…), but an audit id is accepted too — that is how you read an audit created in the app, which has no job (see ?source=all on the list).
# in progress ->
{ "id": "job_...", "status": "running", "auditId": "...",
"project": "Acme Store", "createdAt": "..." }
# error ->
{ "id": "job_...", "status": "error", "errorCode": "crawl_failed",
"errorMessage": "Failed to fetch page content", "createdAt": "..." }
# completed -> the full AuditResultV1 (below)
{ "id": "job_...", "status": "completed", "scores": { ... }, ... }POST /api/v1/audits/bulk — many URLs in one request
Submits up to 50 URLs at once. They enter a queue and run one after another, and keyword data is fetched for the whole batch in one call instead of one per URL — which makes it cheaper and faster than looping over POST /audits.
curl -X POST https://app.citationone.com/api/v1/audits/bulk \
-H "Authorization: Bearer co_live_..." \
-H "Content-Type: application/json" \
-d '{
"project": "Acme Store",
"urls": [
{ "url": "https://example.com/a", "keyword": "keyword a" },
{ "url": "https://example.com/b" },
"https://example.com/c"
]
}'
# 202 ->
{ "id": "V1StGXR8...", "status": "queued", "urlCount": 3,
"project": "Acme Store", "apiVersion": "v1.2" }urls— 1 to 50 entries. Each is either a bare address ("https://…") or an object{ url, keyword }. Withoutkeywordthe phrase is detected automatically.project— optional, assigns every audit in the batch to one project. The same tag as inPOST /audits.- Duplicate addresses are removed before the batch starts — otherwise the same URL would cost two credits.
- Credits: 1 per address we managed to fetch. Unreachable addresses are not charged. The balance is checked before any content is fetched, so insufficient credits give you a
402immediately rather than several minutes in. - One bulk request at a time per account. Another one while the first is still running returns
409 bulk_in_progresstogether withblockingBulkId— pass it toGET /api/v1/audits/bulk/{id}or to cancel, to unblock the account. Idempotency-Key(header, optional) — a retried request with the same key returns the same batch with200instead of starting a second paid crawl. Recommended for automated retries.- A batch with no progress for 15 minutes (e.g. after a failure on our side) is closed automatically as
error, and credits for audits that never started return to the account — the account is not left blocked for further batches.
The result is indistinguishable from individually submitted audits. A batch does not create a separate entity in the app: every audit shows up normally in the list, has its own job_… and is served by the same endpoints — reading the result, sharing, filtering by project. Bulk is purely a way of submitting.
Batch status: GET /api/v1/audits/bulk/{id}
{
"id": "V1StGXR8...", "status": "running", "progress": 66,
"urlCount": 3, "project": "Acme Store", "creditsCharged": 3,
"counts": { "total": 3, "completed": 2, "failed": 0, "pending": 1, "skipped": 0 },
"entries": [
{ "url": "https://example.com/a", "keyword": "keyword a", "status": "completed",
"jobId": "job_...", "auditId": "...", "cqs": 72, "aiCitability": 6.4, "error": null }
],
"apiVersion": "v1.2"
}- One call returns per-URL scores for the whole batch — you do not need 50 separate
GETs. Passentries[].jobIdtoGET /api/v1/audits/{id}only when you want the full report for a given audit. status, both for the batch and for entries, uses the same vocabulary as single audits:queued,running,completed,error(batches addcancelled, entries addskipped).- The counters sum to
total, so you can readpendingdirectly as "how many are left". - Cancelling:
POST /api/v1/audits/bulk/{id}/cancel— stops running audits and refunds credits for those that never started (creditsRefundedin the response). A batch that already finished returns409.
Lost the identifiers? GET /api/v1/audits?bulkId=<id> returns every job from that batch, and each row of the list carries a bulkId field.
GET /api/v1/audits — job list
Inventory and recovery of identifiers when you lose them on your side. Returns jobs (job_…) — the id from this list goes straight into GET /api/v1/audits/{id}.
curl "https://app.citationone.com/api/v1/audits?project=Acme%20Store&status=completed&page=1&limit=25" \
-H "Authorization: Bearer co_live_..."
# 200 ->
{
"data": [
{ "id": "job_...", "status": "completed", "auditId": "...",
"project": "Acme Store",
"input": { "url": "...", "keyword": "...", "language": "en" },
"scores": { "cqs": 72, "aiCitability": 6.4 },
"creditsCharged": 1, "errorCode": null,
"createdAt": "...", "updatedAt": "...",
"reportUrl": "https://app.citationone.com/audyt/..." }
],
"page": 1, "limit": 25, "total": 137, "hasMore": true, "apiVersion": "v1.2"
}Parameters (query string):
| Field | Default | Description |
|---|---|---|
| project | no filter | Exact project name (there are no project identifiers — a project is its name, see POST). The special value __none__ returns only unassigned audits. An empty ?project= is ignored and does NOT mean "unassigned" — this protects you from an unsubstituted template variable silently narrowing your inventory. |
| status | no filter | queued | running | completed | error. Any other value returns 400. |
| page | 1 | Page number, from 1. |
| limit | 25 | Page size, max 100 (higher values are clamped, not rejected). |
| bulkId | no filter | Batch identifier (`bulk_…`) — returns every audit from one bulk request. Somebody else's `bulkId` returns an empty list, not their data. |
| createdAfter | no filter | Lower bound on creation date, inclusive. Accepts `2026-08-13` or a full ISO-8601 timestamp (`2026-08-13T10:00:00Z`). A bare date means the start of the UTC day. |
| createdBefore | no filter | Upper bound, inclusive. A bare date means the END of the UTC day — `createdBefore=2026-08-13` covers all of 13 August, not just its midnight. An inverted range (`createdAfter` later than `createdBefore`) returns 400 rather than an empty list. |
| source | api | `api` = audits submitted through the API (they have a `job_…` id). `panel` = audits created in the app, which have no job — `id` is null and you address them by `auditId`. `all` = both. The default stays narrow on purpose: were the list to start returning panel audits, your counters and pagination would change overnight without you touching anything. |
| domain | no filter | Domain of the audited page, e.g. `example.com`. A full address is accepted too — we extract the host. `www.` is ignored, subdomains are included (`blog.example.com` falls under `example.com`), but lookalikes are not (`notexample.com` does not match). An invalid domain returns 400, so a typo does not look like "no audits". Audits submitted via `content` (no address) never match this filter. |
project filters on the current assignment: if an audit was moved to another project in the app, it appears under the new project, not the one given at submission time.
Picking up work started by a human. Pass ?source=all and the list also returns audits created in the app. Those have id: null and source: "panel" — they have no job, because nobody submitted them through the API. Address them by auditId: GET /api/v1/audits/{id} accepts an audit id just as it accepts a job_… id, so an agent can read a report a person started without any special casing.
What panel audits do not support: cancelling, retrying, Idempotency-Key and webhooks — all of those act on a job, and there is none. Sharing works normally, but through the app rather than POST /audits/{id}/share, which also addresses jobs. Fields carried by the job rather than the audit come back empty: errorCode is always null, and creditsCharged is reconstructed from the audit (0 if the credit was refunded after a failure).
One limitation stays. Sorting is newest-first, so an audit created while you page through shifts subsequent pages by one position. When dumping the whole history either walk the pages backwards (page descending) or de-duplicate by auditId.
Result: AuditResultV1
{
"id": "job_...",
"status": "completed",
"createdAt": "2026-06-12T10:00:00.000Z",
"completedAt": "2026-06-12T10:04:12.000Z",
"input": { "url": "...", "keyword": "...", "language": "en",
"project": "Acme Store" },
"scores": { "cqs": 72, "aiCitability": 6.4 },
"csi": { "centralEntity": "...", "sourceContext": "...",
"centralSearchIntent": "...", "predicate": "informational" },
"dimensions": [
{ "id": "csiAlignment", "name": "...", "score": 7.5, "summary": "...",
"strengths": ["..."],
"problems": [ { "title": "...", "problem": "...", "suggestion": "...",
"impact": "high", "section": "...", "actionType": "add" } ] }
],
"eeat": { "experience": 6, "expertise": 7, "authority": 5, "trust": 6, "average": 6.0 },
"recommendations": [
{ "priority": "P1", "title": "...", "dimension": "chunk", "actionType": "add",
"before": "...", "after": "...", "impact": "...", "estimatedCqsDelta": 3.2,
"competitorUrls": [ { "url": "...", "domain": "..." } ] }
],
"benchmark": { "keyword": "...", "searchVolume": 1900, "serpOrganicCount": 10,
"paa": ["..."], "related": ["..."] },
"meta": { "creditsCharged": 1, "apiVersion": "v1.2",
"reportUrl": "https://app.citationone.com/audyt/...",
"shareUrl": "https://app.citationone.com/share/...?lang=en",
"shareExpiresAt": null }
}| Field | Type / range | Description |
|---|---|---|
| input.url | string | null | The audited address. null for audits submitted via `content` — there was no address. |
| input.project | string | null | Project the audit is assigned to. Reflects the CURRENT state (`audits.project`), so a reassignment in the panel is visible here. null = unassigned. You also get an echo of this field in the GET response for queued/running status, before any result exists. |
| scores.cqs | 0–100 | null | Content Quality Score. |
| scores.aiCitability | 0–10 | null | AI Citability Score (likelihood of being cited by AI Search). |
| csi | object | Central Search Intent: entity, context, intent, predicate. |
| dimensions[] | list | Quality dimensions (CSI-A, density, eav, bluf, chunk, cor, tfidf, srl, queryFanout, effort) — score 0–10, summary, strengths[], problems[]. |
| eeat | object | null | E-E-A-T: experience/expertise/authority/trust (0–10) + average. |
| recommendations[] | list | Prioritised BEFORE/AFTER recommendations (priority P1/P2/P3, dimension, actionType, impact, estimatedCqsDelta, competitorUrls[]). |
| benchmark | object | null | Benchmark summary: keyword, searchVolume, serpOrganicCount, paa[], related[]. |
| meta | object | creditsCharged, apiVersion, reportUrl (full report in the app — requires login), shareUrl (public read-only report without login — null until you ask for a link via share: true or POST /audits/{id}/share), shareExpiresAt (when the link stops working; null = never — the date remains even AFTER expiry, when shareUrl is already null). |
Webhooks — get notified instead of polling
Pass webhookUrl when submitting an audit and we will POST you the result once it finishes. An audit takes minutes, so polling in a loop costs you both requests and delay. It works the same for POST /api/v1/audits and POST /api/v1/audits/bulk — in a bulk request you get a separate event for each audit, exactly as if you had submitted them individually. There is no "whole batch done" event: audits from a batch are independent.
curl -X POST https://app.citationone.com/api/v1/audits \
-H "Authorization: Bearer co_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/article",
"webhookUrl": "https://your-service.com/hooks/citationone"
}'Event payload (audit.completed or audit.failed):
{
"event": "audit.completed",
"job": {
"id": "job_V1StGXR8...",
"status": "completed",
"auditId": "aud_...",
"bulkId": null,
"project": "Acme Store",
"input": { "url": "https://example.com/article", "keyword": "...", "language": "en" },
"creditsCharged": 1,
"errorCode": null,
"createdAt": "2026-08-13T10:00:00.000Z",
"completedAt": "2026-08-13T10:04:12.000Z"
},
"apiVersion": "v1.2"
}We send the event for failures too (audit.failed) — otherwise, having dropped polling, you would wait forever for something that never arrives.
Headers we send:
| Header | Description |
|---|---|
| X-CitationOne-Signature | Signature in the form `t=<unix>,v1=<hex>` — see verification below. |
| X-CitationOne-Event | `audit.completed` or `audit.failed`. |
| X-CitationOne-Delivery | Delivery identifier, equal to the job `id` and CONSTANT across retries. Use it to discard duplicates. |
Verify the signature before trusting the payload. Your endpoint is public, and without verification anyone who learns its address can impersonate us and feed you a fake audit result. The secret is in GET /api/v1/me as apiKey.webhookSecret. It belongs to the account, not to an individual key, so it stays the same across all your keys.
apiKey.webhookSecret is returned only for keys with the `audits:write` scope — if the field is absent, that is why. The secret does not merely verify a signature: it also produces one, so a read-only key handed to a third party must not carry the ability to forge audit results into your pipeline. Read it with the same key you submit audits with.
import { createHmac, timingSafeEqual } from 'crypto';
function verify(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = parseInt(parts.t, 10);
// Reject events older than 5 minutes — otherwise a captured request can be replayed.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const expected = createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
if (expected.length !== parts.v1.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}The signature is computed over "<t>.<raw body>", so verify against the raw request body — after JSON.parse and a re-stringify the bytes may differ and the signature will not match. The timestamp is inside the signed string on purpose: were it only a header, it could be swapped and an old event replayed indefinitely.
Delivery and retries:
httpsis required (the event carries your audit result). Private and local addresses are rejected — including hostnames that resolve to one.- We do not follow redirects — respond
2xxdirectly. - Up to 3 attempts, with 1 s and 4 s pauses, and a 10 s response timeout. A
4xx(other than408and429) ends the attempts immediately — that is a permanent error. A3xxalso ends them, since a redirect will be returned identically on every retry. - A webhook is an acceleration, not a guarantee. If your endpoint was unavailable, the result still waits in
GET /api/v1/audits/{id}— treat polling as your reconciliation fallback. - A failure of your receiver never invalidates the audit or refunds the credit — the audit did run.
GET /api/v1/projects — project list
Returns the project names used across your audits, with an audit count and the date of the most recent one. Its purpose is to suggest existing names so a typo does not break grouping.
curl https://app.citationone.com/api/v1/projects \
-H "Authorization: Bearer co_live_..."
# 200 ->
{
"data": [
{ "name": "Acme Store", "auditCount": 42, "lastAuditAt": "2026-08-12T09:14:00.000Z" }
],
"total": 1, "apiVersion": "v1.2"
}There is no `projectId`, and no creating or deleting projects — this is deliberate. In this product a project is a label on an audit, not a separate entity: you assign it with the project field when submitting, and "moving" an audit means giving it a different name. An empty project could not exist, because there are exactly as many projects as there are names in use. Create a project simply by naming it in POST /audits — no separate call. Grouping is by EXACT name: "Acme Store" and "acme store" are two different projects.
The list covers all your audits, including those created in the app. That is why auditCount is sometimes larger than the number of results from GET /api/v1/audits?project=… — that list shows only audits submitted through the API.
GET /api/v1/credits/usage — balance and history
Credit balance together with the history of charges and top-ups — for automated budget control.
curl "https://app.citationone.com/api/v1/credits/usage?page=1&limit=50" \
-H "Authorization: Bearer co_live_..."
# 200 ->
{
"remaining": 128,
"used": 372,
"unlimited": false,
"history": [
{ "type": "audit", "id": "aud_...", "delta": -1, "refunded": false,
"url": "https://example.com/article", "project": "Acme Store",
"createdAt": "2026-08-12T09:14:00.000Z" },
{ "type": "purchase", "id": "pay_...", "delta": 100, "refunded": false,
"url": null, "project": null, "createdAt": "2026-08-01T12:00:00.000Z" }
],
"page": 1, "limit": 50, "total": 373, "hasMore": true, "apiVersion": "v1.2"
}remaining is the balance and the source of truth. used and history are reconstructed from your audits and payments, because charges have no separate ledger. That has three consequences worth knowing before you build a budget alarm on it: a manual balance correction by an administrator leaves no trace and will not appear here; administrator accounts do not pay for audits (unlimited: true, used: 0); and summing delta across the history need not reproduce the balance exactly. Use remaining to enforce a limit, and the history to account for where credits went.
A credit refunded after a failed audit has refunded: true and delta: 0 — the entry stays so you can see the attempt happened but cost nothing.
Errors
HTTP-layer errors have the shape { "error": { "code", "message", "requestId" } }. Some errors add fields beyond that — see "Extra error fields" below.
| HTTP | code | Meaning |
|---|---|---|
| 403 | insufficient_scope | The key lacks the scope this endpoint requires: audits:read for reading (GET), audits:write for submitting audits and managing sharing. Check the key scopes in GET /api/v1/me. |
| 409 | bulk_in_progress | Another bulk request is already running on this account. Wait for it to finish or cancel it. |
| 409 | already_finished | An attempt to cancel a bulk request that has already finished. |
| 409 | not_completed | An attempt to share an audit that is not finished (queued/running/error). |
| 400 | invalid_request | Missing url and content, both supplied at once, a bad or disallowed URL (SSRF), bad language, keyword > 200 characters, project > 100 characters or not a string, content outside 50–200,000 characters, title > 300 characters, share not a boolean, shareExpiresInDays outside 1–365 / not an integer / supplied without share: true, or an invalid webhookUrl. |
| 401 | invalid_api_key | Missing, bad or revoked key. |
| 402 | insufficient_credits | No audit credits left. |
| 404 | not_found | The job does not exist or does not belong to your key. |
| 429 | rate_limited | Request or concurrent-audit limit exceeded. Retry-After header + retryAfter field (seconds), plus X-RateLimit-* headers. |
| 500 | internal_error | Unexpected server error. Quote the requestId when reporting it. |
Extra error fields. Machine-readable detail on top of code/message, so you can validate on your side instead of parsing prose:
| Field | Appears in | Description |
|---|---|---|
| invalidUrls[] | 400 invalid_request on `POST /audits/bulk` | Which addresses were rejected and why: `[{ "url": "…", "error": "…" }]`, capped at the first 5. The message itself is "Invalid URLs in the list (n)" where n is the total count — so n may exceed the array length. Nothing is charged and no audit starts: the whole batch is rejected, not the valid part of it. |
| retryAfter | 429 rate_limited | Seconds until the window resets — the same value as the `Retry-After` header. |
| blockingBulkId | 409 bulk_in_progress | Id of the batch still running on the account, plus `blockingStatus`. Pass it to `GET /api/v1/audits/bulk/{id}` or to cancel — without it a pure API client has no way of learning it. |
Frequent validation messages for `POST /audits/bulk`: urls must be a non-empty array (missing or empty body), Too many URLs (max 50 per request), each item needs a string url and each item must be a string or an object with a url (malformed entry), Invalid URLs in the list (n) (see invalidUrls[] above). All of them are 400 invalid_request.
When an audit fails in the background, GET returns status: "error" with errorCode and errorMessage. Possible errorCode values:
| errorCode | Meaning |
|---|---|
| crawl_failed | Could not fetch the page content (bad URL, blocked, empty page). |
| keyword_required | No keyword given and none could be detected — supply keyword explicitly. |
| csi_failed | Could not derive the CSI from the content. |
| content_too_large | The content exceeds the allowed size. |
| insufficient_credits | Credits ran out at the moment the audit started. |
| audit_failed | The audit did not complete successfully. |
| config_error | Missing server-side configuration (e.g. a model key). |
| internal_error | Unexpected error. |
Limits and credits
- Credits: 1 audit = 1 credit. The credit is charged only after the content has been fetched and the CSI analysed (if preparation fails, no credit is taken). No refund once an audit fails mid-computation — same as in the app.
- Rate limit: 20 requests/min per key by default. Above that —
429with aRetry-Afterheader. Sharing (POST/DELETE /audits/{id}/share) and batch cancellation have their own separate windows, so heavy sharing does not eat into your budget for submitting audits. - Limit headers:
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(unix seconds) come back on every response from the submission endpoints, not only on429— so you can throttle before you hit the limit. - `X-Request-Id`: every response carries this header, and errors additionally carry
error.requestId. Quote it in a support request and we will find your call in the log without guessing by timestamp. If you send your ownX-Request-Id, we honour it and return the same value, which stitches our log to yours. - Concurrency: at most 3 audits at a time per account (
429above that). - Input limits: keyword ≤ 200 characters; project ≤ 100 characters; title ≤ 300 characters; content (fetched or supplied via
content) 50–200,000 characters; bulk requests ≤ 50 addresses and one at a time per account. - Key safety: we store only the hash — a lost key cannot be recovered, so create a new one and revoke the old.
Versioning
The path carries the major version, the apiVersion field the full one. All endpoints live under /api/v1 and that prefix will only change on a breaking change (then /api/v2, with v1 maintained until a deadline announced in advance). Additions that break nothing raise the number after the dot — which is why responses currently return v1.2 while you change not a single address.
apiVersion sits at the top level of every response. One exception: in the full audit result it lives under meta — that is where it has been since v1, and moving it would be exactly the breaking change we are avoiding.
What `v1.2` brought: webhooks on job completion, auditing content supplied directly (content instead of url), GET /projects, GET /credits/usage, date and domain filters on the job list, X-RateLimit-* headers on successful responses and X-Request-Id on errors. The earlier v1.1 added projects, the job list, sharing after the fact and bulk requests. All of it alongside existing fields — an integration written against v1 keeps working unchanged.