REST API

Run analyses, research and Auto Research from your own code, with spend limits and webhooks.

7 min readUpdated September 2026

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.

Note
Limits count credits reserved when a job starts. Refunds issued when a run finishes under budget (research runs that return fewer rows, Auto Research runs that spend less than the tier) are not subtracted from the counters.

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 cursor and limit (max 100) and return hasMore and nextCursor. Loop while hasMore is true: when the last page happens to be exactly full, one more request returns an empty page with hasMore: false. Articles paginate with offset and report an exact total.
  • Jobs are asynchronous: a POST that starts one returns 202 with the run. Poll GET with ?wait=25 to long-poll until it finishes (the server caps the wait), or use a webhook.
  • Send an Idempotency-Key header on any POST you might retry. A retry with the same key returns the stored response instead of starting (and charging for) a second job; the replay carries Idempotent-Replayed: true.
  • Result rows omit the raw SERP by default. Add ?include=serp to 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

MethodPathWhat it does
GET/api/v1/meCredits, subscription state, this key's scopes, limits and spend so far
GET/api/v1/usageCredits reserved through the API, newest first (cursor, limit, since)
GET/api/v1/estimateCredits for a job: operation=analysis&keywordCount, research&resultLimit, auto_research[&tier] (no tier lists all three tiers with costs)
GET/api/v1/locationsDataForSEO location codes (?q= filters by name)
GET/api/v1/locations/{code}One location and the languages available for it
GET/api/v1/languagesLanguage codes (?location= narrows to a location)
GET/api/v1/event-typesEvent 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.

MethodPathWhat it does
POST/api/v1/analysesStart. Body: keywords[], location (2840), language (en), name?
GET/api/v1/analysesList runs (status, cursor, limit)
GET/api/v1/analyses/{id}Status, progress, keywords. ?wait=N long-polls
GET/api/v1/analyses/{id}/resultsPer-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.

MethodPathWhat it does
POST/api/v1/researchStart. Body: type + the fields for that type (below), resultLimit, location, language, filters?, sort?, name?
GET/api/v1/researchList runs (type, status, cursor, limit)
GET/api/v1/research/{id}Status and totals. ?wait=N long-polls
GET/api/v1/research/{id}/resultsKeyword rows, or competitor domains for organicCompetitors
POST/api/v1/research/{id}/analyzeRun the full analysis on selected keywords of a finished run. Body: keywords[]
typeApp nameRequired fieldsNotes
keywordKeyword IdeasseedKeywordmode: "suggestions" (phrase matches, default) or "ideas" (related terms). Wildcards (*) work in suggestions mode.
domainNiche KeywordsdomainKeywords the site ranks for, by relevance.
competitorRanked KeywordstargetA domain or a page URL; rows carry rank, estimated traffic and ranking URL.
keywordGapKeyword Gapdomain1, domain2Keywords domain1 ranks for that domain2 does not.
organicCompetitorsOrganic CompetitorsdomainOptions: 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.

MethodPathWhat it does
POST/api/v1/auto-research/plansCreate a plan. Body: brief, domain?, tier (required), location, language, constraints?, autoApprove?
GET/api/v1/auto-research/plansList 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}/reviseRevise a draft from a natural-language instruction. Body: instruction
POST/api/v1/auto-research/plans/{id}/approveCharge the tier and start the run. Body: expectedCredits (must equal the plan's estimatedCredits). Returns the run
GET/api/v1/auto-research/runsList runs
GET/api/v1/auto-research/runs/{id}Status, phase, progress, band counts, article count. ?wait=N
GET/api/v1/auto-research/runs/{id}/reportStrategy, band counts, screening funnel, top topics
GET/api/v1/auto-research/runs/{id}/articlesThe Articles tab (offset, limit, includePeripheral, topic, search)
GET/api/v1/auto-research/runs/{id}/topicsThe Topics tab
GET/api/v1/auto-research/runs/{id}/mapThe topical map: hubs, nodes and suggested internal links (?hub=N)
GET/api/v1/auto-research/runs/{id}/keywordsThe Keywords tab (band=core|peripheral|tangential|junk|all, cursor, limit, include=serp)
POST/api/v1/auto-research/runs/{id}/analyzeFull analysis of selected keywords. Body: keywords[]
POST/api/v1/auto-research/runs/{id}/regroupRedo the articles. Body: instructions?, titleStyle?, reband?
POST/api/v1/auto-research/runs/{id}/titlesRewrite 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

MethodPathWhat it does
GET/api/v1/collectionsList collections
POST/api/v1/collectionsCreate. 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}/keywordsSaved keywords (cursor, limit, include=serp)
POST/api/v1/collections/{id}/keywordsSave 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.

MethodPathWhat it does
GET/api/v1/webhooksList endpoints
POST/api/v1/webhooksCreate. 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}/testQueue a webhook.test event
GET/api/v1/webhooks/{id}/deliveriesRecent delivery attempts
GET/api/v1/eventsEvent 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

StatuscodeMeaning
400validation_error, invalid_jsonBad input; details.issues lists the fields
401unauthorized, invalid_key, revoked_key, expired_keyFix the Authorization header or create a new key
402insufficient_creditsBuy credits or wait for the monthly allowance
402spend_limit_exceededA key or account limit; details.limitType, details.remaining
403insufficient_scope, subscription_required, auto_research_disabled, tier_not_allowedPermission problems
404not_foundUnknown id, or one owned by another account
409conflict, run_not_finished, idempotency_in_progressDuplicate job, run still running, or a retry racing the original
422idempotency_key_reusedSame Idempotency-Key, different request
429rate_limitedSlow down; Retry-After says how long
503service_unavailableQueue or database unavailable; nothing was charged