DOCS / API V1

The SEO Agent API.

Run SEO audits, real keyword research, article generation, and CMS publishing over REST. Built for agents, scripts, and workflow tools.

Base URL: https://www.theseoagent.ai/api/v1

All requests use HTTPS and JSON. There is no separate API subdomain. The apex domain redirects to www with a method-preserving 307, so point clients at www.theseoagent.ai directly.

01

Authentication

Every request carries an API key as a bearer token. Keys look like sk_live_... and are minted in the app under Settings, API keys (org admins only; the full key is shown once at creation). Keys are scoped to your organization and carry all scopes by default.

curl https://www.theseoagent.ai/api/v1/projects \
  -H "Authorization: Bearer sk_live_..."

A missing or invalid key returns 401 unauthorized. A valid key on an organization without an active subscription returns 402 subscription_required. API access is included in the $99/mo plan, no separate API pricing. Pricing.

02

Response envelope

Every response is JSON with the same envelope. Success wraps the payload in data; errors carry a stable machine-readable code. Both include a request_id you can quote in support requests.

// Success
{ "data": { ... }, "request_id": "3f6c..." }

// Error
{ "error": { "code": "not_found", "message": "Project not found in this organization." },
  "request_id": "3f6c..." }

Error codes you can hit on any endpoint:

StatusCodeMeaning
400invalid_bodyBody failed validation. The error includes the failing fields.
400invalid_jsonBody is not valid JSON.
401unauthorizedMissing, malformed, or revoked API key.
402subscription_requiredNo active subscription on the key's organization.
403insufficient_scopeKey lacks a required scope. The error lists required_scopes and missing_scopes.
404not_foundResource does not exist in your organization.
429rate_limitedOver a rate limit. Includes retry_after_seconds and a Retry-After header.
500internal_errorOur fault. Retry with the same Idempotency-Key.
03

Rate limits

Per key: 60 requests per minute and 1,000 per day. The endpoints that spend real data budget carry their own daily caps, applied per key and per organization:

  • POST /agents/keyword-research: 10 runs per day.
  • POST /agents/audit: 5 runs per day.
  • POST /agents/generate: 1 article per project per day (the same cap as the in-app Generate button). Returns 429 daily_limit_reached when spent.

Every 429 includes retry_after_seconds in the body and a standard Retry-After header. Back off and retry after that window.

04

Idempotency

Send an Idempotency-Key header on any POST to make retries safe. The first call reserves the key and stores its response; a retry with the same key and the same body replays the stored response instead of running the operation twice.

curl -X POST https://www.theseoagent.ai/api/v1/agents/generate \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: 9f2b7c1e-article-monday" \
  -H "Content-Type: application/json" \
  -d '{"project_id": "...", "keyword": "ai seo agent"}'

Reusing a key with a different body returns 409 idempotency_key_reused. Retrying while the first request is still running returns 409 request_in_progress.

05

Endpoints

GET/projectsscope: projects:read

List the projects in your organization, newest first.

{ "data": { "projects": [
    { "id": "...", "name": "Acme", "domain": "acme.com", "status": "active",
      "default_language": "en", "created_at": "...", "updated_at": "..." }
] }, "request_id": "..." }
GET/articlesscope: articles:read

List articles, newest first, cursor-paginated. Query params: project_id, status, limit (1 to 100, default 20), and cursor (pass the next_cursor from the previous page; null means you have everything).

{ "data": { "articles": [
    { "id": "...", "project_id": "...", "title": "...", "slug": "...",
      "status": "published", "word_count": 1620, "quality_score": 87,
      "ai_score": null, "published_url": "https://acme.com/blog/...",
      "published_at": "...", "created_at": "...", "updated_at": "..." }
  ], "next_cursor": "2026-06-01T03:12:45.000Z" }, "request_id": "..." }
GET/articles/:idscope: articles:read

Fetch one article with its full body. Adds meta_description, content_markdown, content_html, image_url, and tags to the list fields. Returns { "data": { "article": { ... } } }.

POST/agents/generatescope: articles:write

Write an article from a keyword. Async: responds 202 immediately and the article is generated in the pipeline (research, draft, fact-check, quality gate).

// Request
{ "project_id": "...", "keyword": "best crm for contractors",
  "guide": "optional angle, max 200 chars",
  "custom_instructions": "optional, max 500 chars" }

// 202 response
{ "data": { "job_id": "...", "status": "queued",
  "poll_url": "/api/v1/jobs/<job_id>" }, "request_id": "..." }

Errors: 429 daily_limit_reached (one article per project per day), 503 service_unavailable, 502 generation_failed.

POST/agents/keyword-researchscope: keywords:run

Run real keyword research for a project: live search volume, difficulty, scoring, clustering. Re-runs are allowed and top the project's keyword queue back up to its target. Async, 202.

// Request
{ "project_id": "..." }

// 202 response
{ "data": { "job_id": "...", "status": "queued", "keywords_requested": 30,
  "poll_url": "/api/v1/jobs/<job_id>" }, "request_id": "..." }

Errors: 400 project_missing_domain (set a domain in project settings first), 409 bootstrap_already_running (a run is in progress, poll it), 409 queue_full (the queue is already at target; it refills automatically as it drains).

POST/agents/auditscope: audit:run

Run the full SEO audit for any public domain: on-page diagnosis, rankings, competition, backlink gap, AI-content score. Async, 202. The finished report is a public page at the returned report_url.

// Request
{ "domain": "example.com" }

// 202 response
{ "data": { "job_id": "...", "status": "running", "domain": "example.com",
  "report_url": "https://www.theseoagent.ai/seo-audit/example.com",
  "poll_url": "/api/v1/jobs/<job_id>" }, "request_id": "..." }

Errors: 400 invalid_domain, 409 audit_already_running (one audit per domain at a time; the error includes the running job_id and poll_url). Emits the audit.completed webhook when it finishes.

POST/agents/publishscope: articles:publish

Publish an existing article to the connected CMS (WordPress, Webflow, Shopify, Wix, Ghost, or a custom webhook receiver). Synchronous; returns the dispatch result inline.

// Request
{ "article_id": "..." }

// 200 response
{ "data": { "dispatch": { ... } }, "request_id": "..." }
GET/jobs/:idscope: articles:read

Poll any async job. The response shape depends on the job type; all of them carry a terminal status.

// Article generation job
{ "data": { "job": { "id": "...", "status": "succeeded", "article_id": "...",
  "current_stage": null, "error": null, "cost_usd": 0.41, ... } } }

// Keyword research job
{ "data": { "job": { "id": "...", "type": "keyword_research", "project_id": "...",
  "status": "running", "current_stage": "scoring", "error": null, ... } } }

// Audit job
{ "data": { "job": { "id": "...", "type": "audit", "domain": "example.com",
  "status": "succeeded",
  "report_url": "https://www.theseoagent.ai/seo-audit/example.com",
  "error": null, ... } } }
06

Async jobs

The three agent endpoints that do real work (generate, keyword-research, audit) respond 202 with a job_id and a poll_url, then run in the background. Poll GET /jobs/:id every few seconds until the status is terminal:

  • succeeded: done. Generation jobs set article_id; audit jobs set report_url.
  • failed: the error field carries a stable code or message.
  • canceled: the job was canceled in the app.

Non-terminal statuses are pending and running. Keyword research typically lands in 2 to 6 minutes; audits in 1 to 5 minutes; generation in a few minutes depending on article length. If you would rather not poll, subscribe to webhooks below.

07

Webhooks

Subscribe at Settings, Webhooks. Two events fire today: article.published (an article was published to your CMS) and audit.completed (an audit finished, successfully or not). Two more are reserved and subscribable now so existing subscriptions keep working when they ship: article.failed and keywords.ready.

Every delivery is a POST with the envelope { event_type, event_id, version: "1", timestamp, data }. Dedupe on event_id. Failed deliveries are retried with exponential backoff for over 24 hours.

// article.published
{ "event_type": "article.published", "event_id": "...", "version": "1",
  "timestamp": "2026-06-10T01:23:45.000Z",
  "data": { "articles": [ {
      "id": "...", "title": "...", "slug": "...",
      "content_markdown": "...", "content_html": "...",
      "meta_description": "...", "image_url": "...", "tags": [],
      "excerpt": "...", "read_time_minutes": 7, "faqs": [],
      "created_at": "...", "published_url": "https://acme.com/blog/..." } ] } }

// audit.completed
{ "event_type": "audit.completed", "event_id": "...", "version": "1",
  "timestamp": "2026-06-10T01:23:45.000Z",
  "data": { "domain": "example.com",
    "report_url": "https://www.theseoagent.ai/seo-audit/example.com",
    "status": "completed" } }

audit.completed fires on failures too, with status: "failed" and report_url: null.

Verifying signatures

Every delivery is signed with your webhook's secret (shown once at creation). The x-seobot-signature header is t=<unix seconds>,v1=<hex> where v1 is the HMAC-SHA256 of `${t}.${rawBody}` keyed with the secret. Verify the HMAC over the raw request body (before any JSON parsing) and reject timestamps older than 5 minutes to block replays:

const { createHmac, timingSafeEqual } = require("node:crypto");

function verify(secret, rawBody, header, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=", 2))
  );
  const t = Number.parseInt(parts.t, 10);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = createHmac("sha256", secret)
    .update(t + "." + rawBody)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express example: capture the RAW body, not the parsed one.
app.post("/hooks/seoagent", express.raw({ type: "*/*" }), (req, res) => {
  const ok = verify(process.env.WEBHOOK_SECRET, req.body.toString("utf8"),
    req.header("x-seobot-signature") ?? "");
  if (!ok) return res.status(401).end();
  const event = JSON.parse(req.body.toString("utf8"));
  // handle event.event_type
  res.status(200).end();
});

The Send test button on Settings, Webhooks fires a signed sample delivery so you can verify your receiver end to end before anything real happens.

08

Get a key

Sign up free, mint a key under Settings, API keys, and start the $1 trial to activate it. API access is part of the single $99/mo plan. Simple pricing. Real cancellation. No tricks.