Everything the app does with data is available over HTTPS: keyword analysis, the five research modes, Auto Research (plans, runs, articles, topics, topical map and keywords), collections, and webhooks that tell you when a job finishes. Account and subscription management stay in the web app. The machine-readable spec is at /api/v1/openapi.json, and the same operations are exposed to AI agents by the MCP server.
Authentication
Create a key on the settings page. Keys look like csk_… and are shown once. Send them as a bearer token:
curl https://clearserp.com/api/v1/me \
-H "Authorization: Bearer csk_your_key_here"Each key has scopes (read, analysis, research, auto_research, collections, webhooks) and its own spend limits. A request that needs a scope the key lacks returns 403 insufficient_scope. Keys are rate limited to 300 requests per minute; a 429 carries a Retry-After header.
Spend limits
Every job that starts through the API costs credits, so budgets are enforced before anything is charged. Three ceilings exist per key (per request, per UTC day, per calendar month) plus two per account (day and month) that apply across all keys. Auto Research can be switched off for a key or for the whole account, and a key can be capped at a tier. When a limit would be exceeded the request fails with 402 spend_limit_exceeded and nothing is charged; details.limitType names the limit and details.remaining what is left.
Conventions
- JSON in, JSON out. Timestamps are ISO-8601 strings. Ids are the same 12-character hashes the web app uses in URLs.
- Errors are
{ "error": { "code", "message", "details?" } }with a meaningful HTTP status (400 validation, 401 auth, 402 credits or limits, 403 scope or subscription, 404, 409 conflict, 429 rate limit). - Lists paginate with
cursorandlimit(max 100) and returnhasMoreandnextCursor. Loop whilehasMoreis true: when the last page happens to be exactly full, one more request returns an empty page withhasMore: false. Articles paginate withoffsetand report an exacttotal. - Jobs are asynchronous: a
POSTthat starts one returns202with the run. PollGETwith?wait=25to long-poll until it finishes (the server caps the wait), or use a webhook. - Send an
Idempotency-Keyheader on anyPOSTyou might retry. A retry with the same key returns the stored response instead of starting (and charging for) a second job; the replay carriesIdempotent-Replayed: true. - Result rows omit the raw SERP by default. Add
?include=serpto get it (pages then hold at most 100 rows).
Quick start
# 1. What will it cost?
curl "https://clearserp.com/api/v1/estimate?operation=analysis&keywordCount=25" -H "Authorization: Bearer $KEY"
# 2. Start an analysis (202)
curl https://clearserp.com/api/v1/analyses -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" -H "Idempotency-Key: 5f1c-…" \
-d '{"keywords":["best running shoes","trail running shoes for women"],"location":2840,"language":"en"}'
# 3. Wait for it, then read results
curl "https://clearserp.com/api/v1/analyses/AbC123xyz456?wait=25" -H "Authorization: Bearer $KEY"
curl "https://clearserp.com/api/v1/analyses/AbC123xyz456/results?limit=100" -H "Authorization: Bearer $KEY"Account and reference data
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/me | Credits, subscription state, this key's scopes, limits and spend so far |
| GET | /api/v1/usage | Credits reserved through the API, newest first (cursor, limit, since) |
| GET | /api/v1/estimate | Credits for a job: operation=analysis&keywordCount, research&resultLimit, auto_research[&tier] (no tier lists all three tiers with costs) |
| GET | /api/v1/locations | DataForSEO location codes (?q= filters by name) |
| GET | /api/v1/locations/{code} | One location and the languages available for it |
| GET | /api/v1/languages | Language codes (?location= narrows to a location) |
| GET | /api/v1/event-types | Event names webhooks can subscribe to |
Analysis
Full SERP analysis of up to 1,000 keywords: difficulty, volume, CPC, the live top-10, weakness detection and a keyword score. Priced by keyword count on the same curve as the app.
| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/analyses | Start. Body: keywords[], location (2840), language (en), name? |
| GET | /api/v1/analyses | List runs (status, cursor, limit) |
| GET | /api/v1/analyses/{id} | Status, progress, keywords. ?wait=N long-polls |
| GET | /api/v1/analyses/{id}/results | Per-keyword results (cursor, limit, include=serp) |
Research
One endpoint drives all five modes through the type field. One credit per 100 results requested; resultLimit is one of 100, 500, 1000, 2500, 5000 or 10000. Unused results are refunded proportionally.
| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/research | Start. Body: type + the fields for that type (below), resultLimit, location, language, filters?, sort?, name? |
| GET | /api/v1/research | List runs (type, status, cursor, limit) |
| GET | /api/v1/research/{id} | Status and totals. ?wait=N long-polls |
| GET | /api/v1/research/{id}/results | Keyword rows, or competitor domains for organicCompetitors |
| POST | /api/v1/research/{id}/analyze | Run the full analysis on selected keywords of a finished run. Body: keywords[] |
| type | App name | Required fields | Notes |
|---|---|---|---|
| keyword | Keyword Ideas | seedKeyword | mode: "suggestions" (phrase matches, default) or "ideas" (related terms). Wildcards (*) work in suggestions mode. |
| domain | Niche Keywords | domain | Keywords the site ranks for, by relevance. |
| competitor | Ranked Keywords | target | A domain or a page URL; rows carry rank, estimated traffic and ranking URL. |
| keywordGap | Keyword Gap | domain1, domain2 | Keywords domain1 ranks for that domain2 does not. |
| organicCompetitors | Organic Competitors | domain | Options: maxRankGroup, excludeTopDomains, topDomainsToExclude[], intersectingDomains[], includeDomainScore (2x credits). |
filters accepts questionsOnly, exclude, contains / containsLogic, doesNotContain / doesNotContainLogic, and volume, cpc, difficulty ranges as {min, max}, plus intents[]. Competitor and gap runs also take rank, estTraffic and url. sort is {field, direction} with field one of search_volume, difficulty, cpc, alphabetical, rank, est_traffic.
Auto Research
Auto Research works in two steps, exactly as in the app. A plan is generated from your brief (free): seeds, competitors, topic axes, constraints and the banding rubric. You review or edit it, then approve it, which charges the tier (Quick 500, Thorough 2,000, Exhaustive 4,000 credits, with the unspent share refunded at the end) and starts the run. The tier is required and has no default: a client that does not know which tier its user wants should ask, with the credit costs attached, rather than pick one (GET /api/v1/estimate?operation=auto_research without a tier returns all three with their costs). Approval takes expectedCredits in the body and it must equal the plan's estimatedCredits; a mismatch is refused, so a client has to have read the estimate before it can spend. If you would rather not review, create the plan with "autoApprove": true and the run starts the moment the plan is ready; the tier's credits are reserved against your limits at that point.
constraints takes the same fields as the app's plan form: minVolume / maxVolume, minDifficulty / maxDifficulty, minCpc / maxCpc, mustContain / mustNotContain (with their Logic companions), questionsOnly and intents[]. Treat intents[] with care. Auto Research already judges the intent of every keyword when it bands results against your brief, so the filter is not needed to keep results on-intent. It narrows by intent at harvest time, before keywords reach the candidate pool, which means fewer candidate keywords and in many cases degraded result quality. It is still the right tool when you specifically want the harvest itself narrowed; just go in knowing the tradeoff.
| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/auto-research/plans | Create a plan. Body: brief, domain?, tier (required), location, language, constraints?, autoApprove? |
| GET | /api/v1/auto-research/plans | List plans |
| GET | /api/v1/auto-research/plans/{id} | The plan document. ?wait=N long-polls while it is generating |
| PATCH | /api/v1/auto-research/plans/{id} | Edit a draft: enable/disable/add/remove seeds and competitors, toggle axes, constraints, tier, autoApprove |
| POST | /api/v1/auto-research/plans/{id}/revise | Revise a draft from a natural-language instruction. Body: instruction |
| POST | /api/v1/auto-research/plans/{id}/approve | Charge the tier and start the run. Body: expectedCredits (must equal the plan's estimatedCredits). Returns the run |
| GET | /api/v1/auto-research/runs | List runs |
| GET | /api/v1/auto-research/runs/{id} | Status, phase, progress, band counts, article count. ?wait=N |
| GET | /api/v1/auto-research/runs/{id}/report | Strategy, band counts, screening funnel, top topics |
| GET | /api/v1/auto-research/runs/{id}/articles | The Articles tab (offset, limit, includePeripheral, topic, search) |
| GET | /api/v1/auto-research/runs/{id}/topics | The Topics tab |
| GET | /api/v1/auto-research/runs/{id}/map | The topical map: hubs, nodes and suggested internal links (?hub=N) |
| GET | /api/v1/auto-research/runs/{id}/keywords | The Keywords tab (band=core|peripheral|tangential|junk|all, cursor, limit, include=serp) |
| POST | /api/v1/auto-research/runs/{id}/analyze | Full analysis of selected keywords. Body: keywords[] |
| POST | /api/v1/auto-research/runs/{id}/regroup | Redo the articles. Body: instructions?, titleStyle?, reband? |
| POST | /api/v1/auto-research/runs/{id}/titles | Rewrite titles and descriptions. Body: titleStyle? |
An article
{
"keyword": "best soap for dry skin",
"title": "The Best Soap for Dry Skin, Chosen by Ingredient",
"metaDescription": "…",
"reason": "Core buying question for the audience; the SERP is dominated by thin listicles.",
"band": "core",
"topic": { "id": "…", "label": "Skin concerns", "axis": "audience" },
"searchVolume": 9900, "cpc": 1.2, "difficulty": 34, "intent": "commercial",
"totalVolume": 12400,
"supportingKeywords": [{ "keyword": "soap for dry skin", "searchVolume": 2400, "difficulty": 30, "intent": "commercial", "band": "core" }],
"serpTop": [{ "position": 1, "url": "https://…", "domain": "…", "title": "…", "description": "…" }],
"serpFeatures": ["people_also_ask", "featured_snippet"],
"provenance": "synthesized", "entity": "dry skin", "entityType": "skin concern", "template": "best soap for {concern}",
"emerging": false, "seriesKey": null, "sourceCount": 3,
"ranking": { "position": 14, "bucket": "close-win" },
"analysis": { "isFullyAnalyzed": false, "keywordScore": null, "lowestDS": null, "highlightedCount": null }
}Collections
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/collections | List collections |
| POST | /api/v1/collections | Create. Body: name, description? |
| GET | /api/v1/collections/{id} | One collection |
| DELETE | /api/v1/collections/{id} | Delete the collection and its keywords |
| GET | /api/v1/collections/{id}/keywords | Saved keywords (cursor, limit, include=serp) |
| POST | /api/v1/collections/{id}/keywords | Save from a run. Body: source {type: "analysis"|"research"|"auto_research", id}, keywords[] |
Webhooks
Register an HTTPS endpoint (on the settings page or through the API) and ClearSERP POSTs a signed JSON event whenever something finishes. Deliveries are retried on failure at 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours; an endpoint that fails twenty events in a row is disabled until you re-enable it. Without a webhook, poll GET /api/v1/events.
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/webhooks | List endpoints |
| POST | /api/v1/webhooks | Create. Body: url, events[] (names, "auto_research.*" or "*"), description?. Returns the secret once |
| GET | /api/v1/webhooks/{id} | One endpoint |
| PATCH | /api/v1/webhooks/{id} | Update url, events, description, status |
| DELETE | /api/v1/webhooks/{id} | Remove |
| POST | /api/v1/webhooks/{id}/test | Queue a webhook.test event |
| GET | /api/v1/webhooks/{id}/deliveries | Recent delivery attempts |
| GET | /api/v1/events | Event log (type, since, cursor, limit) |
| GET | /api/v1/events/{id} | One event |
Event types
- analysis.completed
- analysis.failed
- research.completed
- research.failed
- research.keywords_analyzed
- auto_research.plan.ready
- auto_research.plan.failed
- auto_research.started
- auto_research.auto_approve_failed
- auto_research.completed
- auto_research.failed
- webhook.test
auto_research.completed carries data.run.kind: run, regroup or titles, so a redo and the original run are told apart. auto_research.plan.ready fires when a plan finishes generating; if it was created with autoApprove, auto_research.started follows with the run id, or auto_research.auto_approve_failed explains why it did not start.
Payload
{
"id": "evt_…",
"type": "auto_research.completed",
"apiVersion": "2026-09-01",
"created": "2026-09-01T12:34:56.000Z",
"data": { "run": { "id": "AbC123xyz456", "name": "…", "tier": "thorough", "status": "completed", "kind": "run", "totalResults": 4812, "creditsUsed": 1430, "error": null } }
}Verifying signatures
Every delivery carries ClearSERP-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of `${t}.${rawBody}` with your endpoint's secret. Compare in constant time and reject timestamps older than five minutes. Also sent: ClearSERP-Event, ClearSERP-Event-Id, ClearSERP-Delivery-Id and ClearSERP-Delivery-Attempt.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, header: string, rawBody: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}Error codes
| Status | code | Meaning |
|---|---|---|
| 400 | validation_error, invalid_json | Bad input; details.issues lists the fields |
| 401 | unauthorized, invalid_key, revoked_key, expired_key | Fix the Authorization header or create a new key |
| 402 | insufficient_credits | Buy credits or wait for the monthly allowance |
| 402 | spend_limit_exceeded | A key or account limit; details.limitType, details.remaining |
| 403 | insufficient_scope, subscription_required, auto_research_disabled, tier_not_allowed | Permission problems |
| 404 | not_found | Unknown id, or one owned by another account |
| 409 | conflict, run_not_finished, idempotency_in_progress | Duplicate job, run still running, or a retry racing the original |
| 422 | idempotency_key_reused | Same Idempotency-Key, different request |
| 429 | rate_limited | Slow down; Retry-After says how long |
| 503 | service_unavailable | Queue or database unavailable; nothing was charged |