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/v1All 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.
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.
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:
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_body | Body failed validation. The error includes the failing fields. |
| 400 | invalid_json | Body is not valid JSON. |
| 401 | unauthorized | Missing, malformed, or revoked API key. |
| 402 | subscription_required | No active subscription on the key's organization. |
| 403 | insufficient_scope | Key lacks a required scope. The error lists required_scopes and missing_scopes. |
| 404 | not_found | Resource does not exist in your organization. |
| 429 | rate_limited | Over a rate limit. Includes retry_after_seconds and a Retry-After header. |
| 500 | internal_error | Our fault. Retry with the same Idempotency-Key. |
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). Returns429 daily_limit_reachedwhen spent.
Every 429 includes retry_after_seconds in the body and a standard Retry-After header. Back off and retry after that window.
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.
Endpoints
/projectsscope: projects:readList 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": "..." }/articlesscope: articles:readList 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": "..." }/articles/:idscope: articles:readFetch one article with its full body. Adds meta_description, content_markdown, content_html, image_url, and tags to the list fields. Returns { "data": { "article": { ... } } }.
/agents/generatescope: articles:writeWrite 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.
/agents/keyword-researchscope: keywords:runRun 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).
/agents/auditscope: audit:runRun 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.
/agents/publishscope: articles:publishPublish 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": "..." }/jobs/:idscope: articles:readPoll 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, ... } } }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 setarticle_id; audit jobs setreport_url.failed: theerrorfield 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.
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.
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.