SearchChampSearchChamp
API Documentation

Endpoint reference

Methods, query parameters, payloads, and responses for every supported SearchChamp REST endpoint.

The public v1 surface is ten routes: eight read-only GET requests and two PATCH status updates. All of them return JSON in the common { data, meta, errors } envelope.

This is the complete REST endpoint list

There are currently ten public REST endpoints. Content Writer generation, Research Studio research runs, CMS publishing, actions, backlinks, and opportunities are not missing from this page: they are not public REST routes. Review feature coverage for the supported MCP or app path for each capability.

Shared site parameter

The six site-addressed routes require site as a query parameter. Use the site's exact domain, such as example.com, or its SearchChamp slug.

The four id-addressed routes — GET/PATCH on /v1/api/content/articles/{id} and /v1/api/research/sessions/{id} — deliberately take no site parameter. They resolve against every site your organization can address. Requiring a site there would turn the parameter into an existence oracle: a caller could learn that an id exists by finding the site that makes it resolve.

ResultBehavior
One site matches inside the key's organizationThe request continues.
No accessible site matches404 NOT_FOUND. A site outside the organization is indistinguishable from a missing site.
More than one site matches400 BAD_REQUEST; retry with the exact domain.
site is missing or blank400 BAD_REQUEST.

GET /v1/api/rankings

Returns the keywords whose organic positions moved most over a trailing window.

Query parameterRequiredRules
siteYesDomain or site slug.
daysNoPositive integer; default 7, maximum 90. Invalid/non-positive values use the default.
limitNoPositive integer; default 10, maximum 100. Invalid/non-positive values use the default.
curl --get 'https://api.searchchamp.com/v1/api/rankings' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY" \
  --data-urlencode 'site=example.com' \
  --data-urlencode 'days=14' \
  --data-urlencode 'limit=5'
{
  "data": {
    "site": "example.com",
    "days": 14,
    "movers": [
      {
        "keyword": "seo tools",
        "from": 12,
        "to": 7,
        "delta": 5
      }
    ]
  },
  "meta": {},
  "errors": []
}

delta is from - to, so a positive number means improvement. from, to, and delta can be null when the keyword was unranked at one checkpoint.

GET /v1/api/ai-visibility/summary

Returns the latest 30-day AI Visibility rollup and tracked-prompt lifecycle counts.

Query parameterRequiredRules
siteYesDomain or site slug.
curl --get 'https://api.searchchamp.com/v1/api/ai-visibility/summary' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY" \
  --data-urlencode 'site=example.com'
{
  "data": {
    "site": "example.com",
    "ai_visibility": {
      "score": 0.41,
      "activePrompts": 8,
      "pausedPrompts": 1,
      "summary": {
        "appearanceRate": 0.41,
        "shareOfVoice": 0.18,
        "computedAt": "2026-08-28T08:30:00.000Z"
      }
    }
  },
  "meta": {},
  "errors": []
}

If the site has no AI Visibility setup, the valid 200 response is:

{
  "data": {
    "site": "example.com",
    "setup_required": true,
    "reason": "no_ai_visibility"
  },
  "meta": {},
  "errors": []
}

GET /v1/api/keywords

Returns tracked keywords, best-ranked first. Unranked keywords sort last.

Query parameterRequiredRules
siteYesDomain or site slug.
limitNoPositive integer; default 50, maximum 500. Invalid/non-positive values use the default.
curl --get 'https://api.searchchamp.com/v1/api/keywords' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY" \
  --data-urlencode 'site=example.com' \
  --data-urlencode 'limit=100'
{
  "data": {
    "site": "example.com",
    "keywords": [
      {
        "keyword": "seo tools",
        "position": 7,
        "unranked": false,
        "volume": 1200,
        "clusterLabel": "SEO software"
      }
    ]
  },
  "meta": {},
  "errors": []
}

position, volume, and clusterLabel can be null. unranked explicitly distinguishes a missing/zero ranking from a ranked keyword.

GET /v1/api/audit-issues

Returns the latest completed audit's scores and issue counts. Despite the route name, v1 returns the summary and counts, not individual issue rows. Use the MCP get_audit_issues tool when an AI client needs finding titles and page URLs.

Query parameterRequiredRules
siteYesDomain or site slug.
curl --get 'https://api.searchchamp.com/v1/api/audit-issues' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY" \
  --data-urlencode 'site=example.com'
{
  "data": {
    "site": "example.com",
    "audit": {
      "latestRun": {
        "id": "93a84854-79ce-42a3-b38f-259426b4ce26",
        "completedAt": "2026-08-28T07:00:00.000Z",
        "scores": {
          "overall": 78,
          "aiCrawl": 81,
          "decay": 12,
          "technical": 90
        }
      },
      "issueCounts": {
        "critical": 2,
        "warn": 9,
        "info": 14
      }
    }
  },
  "meta": {},
  "errors": []
}

Any score can be null. If no audit has completed, the route returns 200 with setup_required: true and reason: "no_audit" instead of an audit object.

Content Writer and Research Studio routes

These routes read existing content and flip status only

GET /v1/api/content/articles* and GET /v1/api/research/sessions* read content that already exists in SearchChamp. The two PATCH routes change a status value in SearchChamp's own database and nothing else.

No route on this page ever triggers a Content Writer generation run or a Research Studio research run, and PATCH … {"status":"published"} does not push anything to WordPress, Shopify, or any other connected CMS. Starting a generation or research run remains a SearchChamp app workflow. To perform a real CMS publish, use the MCP publish_article tool instead — see feature coverage.

The status filter is a group, not the raw value

Both feeds accept status=draft or status=published. That vocabulary is a deliberate, stable grouping over SearchChamp's internal state machines, which have more values and keep growing. Every item still returns its raw internal status in the status field, so a response is always self-describing.

FilterArticle statuses includedResearch session statuses included
status=draftdraft, generating, brief_ready, ready_for_review, approved, faileddraft, researching, reviewing, brief_ready, handed_off
status=publishedpublishedpublished
omittedboth groupsboth groups
Never returnedarchivedarchived, failed

archived is a soft delete and is invisible to this surface with or without a filter. A failed research session is also never returned: it has no brief, no pinned sources, and no keywords, so there is nothing to sync. A failed article is returned, because it is a retryable state that still holds a body.

An unrecognized status value is a 400 BAD_REQUEST, never a silent widening to "both". A typo such as ?status=publised must not quietly return drafts you believed you had excluded.

Shared pagination rules

GET /v1/api/content/articles and GET /v1/api/research/sessions are the only paginated routes.

Query parameterRequiredRules
limitNoDefault 25, maximum 100. Non-numeric, zero, or negative values fall back to the default; a larger value is clamped to 100.
offsetNoDefault 0. Non-numeric or negative values fall back to 0.

Both feeds return total (the exact number of matching rows, ignoring limit/offset), returnedCount, and the limit/offset actually applied after clamping — so page with the values SearchChamp echoes back, not the ones you sent. Ordering is updated_at DESC with an id tiebreak, so an offset walk neither duplicates nor drops rows.

GET /v1/api/content/articles

Returns a site's Content Writer articles as a stable editorial feed.

Query parameterRequiredRules
siteYesDomain or site slug.
statusNodraft or published. Omit for both. Any other value is 400.
limitNoDefault 25, maximum 100.
offsetNoDefault 0.
curl --get 'https://api.searchchamp.com/v1/api/content/articles' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY" \
  --data-urlencode 'site=example.com' \
  --data-urlencode 'status=draft' \
  --data-urlencode 'limit=25' \
  --data-urlencode 'offset=0'
{
  "data": {
    "site": "example.com",
    "articles": [
      {
        "id": "0f2c9c2a-5f6f-4a53-9a1e-2b2a1f5f0c11",
        "title": "How to choose SEO tools",
        "slug": "how-to-choose-seo-tools",
        "status": "ready_for_review",
        "locale": "en",
        "wordCount": 1420,
        "targetKeyword": "seo tools",
        "createdAt": "2026-08-20T09:14:00.000Z",
        "updatedAt": "2026-08-27T16:02:00.000Z",
        "publishedAt": null
      }
    ],
    "total": 37,
    "returnedCount": 1,
    "limit": 25,
    "offset": 0
  },
  "meta": {},
  "errors": []
}

title, slug, locale, targetKeyword, createdAt, updatedAt, and publishedAt can each be null. wordCount is 0 rather than null when unknown.

Empty state. A site with no matching articles is a normal 200, not a 404:

{
  "data": {
    "site": "example.com",
    "articles": [],
    "total": 0,
    "returnedCount": 0,
    "limit": 25,
    "offset": 0
  },
  "meta": {},
  "errors": []
}

An offset past the end behaves the same way: articles: [] with the real total still reported, so you can detect that you have walked off the end.

GET /v1/api/content/articles/{id}

Returns one article, including its stored body and SEO metadata.

This route is addressed by id and takes no site parameter. Requiring a site here would let a caller probe whether an id exists by varying the site. Authorization instead binds the request to every site your organization can address.

Path segmentRules
{id}The article id from the feed above. A malformed, unknown, archived, or other-organization id all return the same 404 — a 403 would confirm the id exists.
curl --get 'https://api.searchchamp.com/v1/api/content/articles/0f2c9c2a-5f6f-4a53-9a1e-2b2a1f5f0c11' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY"
{
  "data": {
    "site": "example.com",
    "article": {
      "id": "0f2c9c2a-5f6f-4a53-9a1e-2b2a1f5f0c11",
      "siteId": "5b1d4d1a-1f6b-4b0e-9c33-8d4b2a7f1c90",
      "title": "How to choose SEO tools",
      "slug": "how-to-choose-seo-tools",
      "status": "ready_for_review",
      "locale": "en",
      "wordCount": 1420,
      "targetKeyword": "seo tools",
      "targetKeywords": ["seo tools", "best seo software"],
      "contentType": "blog",
      "metaTitle": "How to Choose SEO Tools",
      "metaDescription": "A practical framework for picking SEO software.",
      "heroImageUrl": "https://cdn.example.com/hero.png",
      "bodyMd": "## Start with the job to be done\n…",
      "bodyHtml": "<h2>Start with the job to be done</h2>…",
      "createdAt": "2026-08-20T09:14:00.000Z",
      "updatedAt": "2026-08-27T16:02:00.000Z",
      "publishedAt": null
    }
  },
  "meta": {},
  "errors": []
}

site is the article's own site domain, and can be null in the rare case the article's site is no longer resolvable. Every string field above can be null; targetKeywords is always an array, possibly empty.

Empty state. There is no "empty" article — an article that cannot be returned is a 404, not a 200 with nulls.

GET /v1/api/research/sessions

Returns a site's Research Studio sessions as a stable feed. Same parameters, clamping, ordering, and status grouping as the article feed.

Query parameterRequiredRules
siteYesDomain or site slug.
statusNodraft or published. Omit for both. Any other value is 400.
limitNoDefault 25, maximum 100.
offsetNoDefault 0.
curl --get 'https://api.searchchamp.com/v1/api/research/sessions' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY" \
  --data-urlencode 'site=example.com' \
  --data-urlencode 'status=published'
{
  "data": {
    "site": "example.com",
    "sessions": [
      {
        "id": "9c1b6f3e-77d4-4c2a-b0aa-3e5c9f2d1a44",
        "title": "Q4 competitor landscape",
        "status": "published",
        "createdAt": "2026-08-11T10:00:00.000Z",
        "updatedAt": "2026-08-26T13:41:00.000Z",
        "publishedAt": "2026-08-26T13:41:00.000Z"
      }
    ],
    "total": 4,
    "returnedCount": 1,
    "limit": 25,
    "offset": 0
  },
  "meta": {},
  "errors": []
}

Sessions with no site are not visible to this API

A Research Studio session whose site is unset is not readable and not publishable through the public API, on either verb.

research_sessions carries no organization column, so a session's tenancy is proven through its site. A session with no site cannot be proven to belong to your organization, and SearchChamp treats an unprovable authorization as a failure rather than a pass. Such a session is simply absent from this feed and returns 404 by id. Assign the session a site in the SearchChamp app to make it externally addressable.

Empty state. As with articles: 200 with sessions: [] and total: 0.

GET /v1/api/research/sessions/{id}

Returns one research session with its latest generated brief, its pinned sources, and its keywords. Addressed by id, no site parameter, and the same uniform 404 for malformed, unknown, archived, failed, site-less, or other-organization ids.

curl --get 'https://api.searchchamp.com/v1/api/research/sessions/9c1b6f3e-77d4-4c2a-b0aa-3e5c9f2d1a44' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY"
{
  "data": {
    "site": "example.com",
    "session": {
      "id": "9c1b6f3e-77d4-4c2a-b0aa-3e5c9f2d1a44",
      "siteId": "5b1d4d1a-1f6b-4b0e-9c33-8d4b2a7f1c90",
      "title": "Q4 competitor landscape",
      "status": "published",
      "createdAt": "2026-08-11T10:00:00.000Z",
      "updatedAt": "2026-08-26T13:41:00.000Z",
      "publishedAt": "2026-08-26T13:41:00.000Z",
      "brief": {
        "id": "b4c1e2f0-2a77-4a0c-8f1e-7d9a0c3b5e21",
        "briefDoc": { "summary": "…", "audience": "…" },
        "briefDocUnknownSections": [],
        "briefDocVersion": 1,
        "contentMd": "## Summary\n…",
        "generatedAt": "2026-08-26T13:30:00.000Z",
        "exportedToContentWriter": false
      },
      "pins": [
        {
          "id": "3d7a1c88-9b21-4a10-8c5d-6f2e1b4a9c07",
          "bucket": "serp",
          "sourceTab": "organic",
          "payload": { "url": "https://competitor.example/blog/post", "title": "Post" },
          "unknownKeys": [],
          "userNote": "Strong intro framing",
          "pinnedAt": "2026-08-12T08:20:00.000Z"
        }
      ],
      "pinsTruncated": false,
      "keywords": [
        {
          "keyword": "seo tools",
          "msv": 1200,
          "difficulty": 41,
          "cpc": 6.2,
          "coveragePct": 35,
          "intent": "commercial"
        }
      ],
      "keywordsTruncated": false
    }
  },
  "meta": {},
  "errors": []
}

brief is null when the session has not been synthesized yet — that is a valid 200, not an error. pins and keywords are always arrays. Large sessions report pinsTruncated / keywordsTruncated as true rather than silently capping, so you always know whether you received everything.

briefDocUnknownSections and each pin's unknownKeys list names only — never values — of fields SearchChamp's producers wrote that this public contract does not yet define, so schema drift is visible instead of leaking undocumented data.

PATCH /v1/api/content/articles/{id}

Marks an article as published in SearchChamp's database. No CMS is contacted.

RequirementValue
API-key permissionwrite (see authentication)
PlanPro/Business and above, same as every route here
Request bodyRequired: {"status":"published"}
Accepted status valuespublished only
Content typeSend Content-Type: application/json. A body sent without it is still parsed as JSON.
curl --request PATCH \
  'https://api.searchchamp.com/v1/api/content/articles/0f2c9c2a-5f6f-4a53-9a1e-2b2a1f5f0c11' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{"status":"published"}'
{
  "data": {
    "site": "example.com",
    "article": {
      "id": "0f2c9c2a-5f6f-4a53-9a1e-2b2a1f5f0c11",
      "status": "published",
      "publishedAt": "2026-08-29T11:02:41.000Z",
      "title": "How to choose SEO tools",
      "slug": "how-to-choose-seo-tools"
    },
    "alreadyPublished": false
  },
  "meta": {},
  "errors": []
}

The article object is the full detail shape from GET /v1/api/content/articles/{id}, re-read from the database after the write, so what you receive is what was actually stored — not what the server intended to store. The example above is abbreviated.

Which articles can be published. approved, ready_for_review, or draft with a body. Anything else is a 409.

PATCH /v1/api/research/sessions/{id}

Marks a research session as published in SearchChamp's database. Identical contract to the article route.

curl --request PATCH \
  'https://api.searchchamp.com/v1/api/research/sessions/9c1b6f3e-77d4-4c2a-b0aa-3e5c9f2d1a44' \
  --header "Authorization: Bearer $SEARCHCHAMP_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{"status":"published"}'
{
  "data": {
    "site": "example.com",
    "session": {
      "id": "9c1b6f3e-77d4-4c2a-b0aa-3e5c9f2d1a44",
      "status": "published",
      "publishedAt": "2026-08-29T11:04:12.000Z",
      "title": "Q4 competitor landscape"
    },
    "alreadyPublished": false
  },
  "meta": {},
  "errors": []
}

Which sessions can be published. A reviewing session, or a draft session with a completed brief. Anything else is a 409. A session with no site is a 404 (see the callout above).

In one rare case the response also carries statusUnchanged: true: SearchChamp's database has not yet had the published status value applied by a pending migration, so the request was accepted but the status was deliberately left alone. Treat statusUnchanged: true as "not published" and retry later — never read the 200 alone as success.

Status-change semantics

These four rules apply to both PATCH routes.

published is the only target

{"status":"published"} is the entire accepted vocabulary. It is an allow-list, not an exclusion list: when SearchChamp's internal state machines gain new values, those values do not become remotely settable. Any other value — including "Published", which is compared exactly and never repaired — is 400 UNSUPPORTED_STATUS.

Repeating the call is safe

Publishing an already-published resource returns 200 with the current state and "alreadyPublished": true. The original publishedAt is not re-stamped, so retries, at-least-once delivery, and concurrent callers cannot rewrite a publication time.

An illegal source state is a typed 409

The current state comes back as data, in meta.current_status, so you never have to parse it out of a message.

{
  "data": null,
  "meta": {
    "current_status": "draft",
    "reason": "empty_body"
  },
  "errors": [
    {
      "code": "INVALID_STATUS",
      "message": "cannot publish a 'draft' article with an empty body"
    }
  ]
}

meta.reason is present only when it adds something the status alone does not: empty_body for an article, no_brief for a research session.

There is no other write

PATCH is the only mutating verb on /v1/api/*, and it is defined only on these two id-addressed paths. PATCH against a feed path, or any other verb against any route, returns 405 METHOD_NOT_ALLOWED with an Allow: GET, PATCH header.

Request bodies, idempotency, and metering

The eight GET routes accept no request body. The two PATCH routes require exactly {"status":"published"}.

There is no idempotency-key header, and none is needed: every GET is a read, and both PATCH routes are naturally idempotent — a repeat returns alreadyPublished: true and leaves the stored publishedAt untouched.

Every successful call is metered. Reads cost 1 credit; each status update costs 5. See errors, limits, and usage for the full table and the organization-wide monthly ceiling, which is evaluated before both verbs. Cache read results in your integration where it makes sense.

On this page