Home/Docs

API tokens

Personal access tokens that let you drive Seoxpert from your own scripts, CI pipelines, and internal dashboards. Pro and Agency plans only.

Pro & Agency

What an API token does

A Seoxpert API token is a long-lived bearer credential you attach to HTTP requests so you can call the same endpoints the dashboard uses — start scans, list scans, fetch reports, manage domains — from your own code. The dashboard, the public API, and the worker all share the same backend; the API isn't a separate surface, just a different way in.

Typical reasons to mint one:

  • Trigger scans from your CI / cron / queue when you want more control than the per-domain deploy hook gives you (e.g. scanning multiple domains from one job, or scanning before deploy).
  • Pull report data into a client dashboard — embed health scores, finding counts, or top-issue tables in your own customer portal.
  • Pipe scan results into BI tools like Metabase, Grafana, or a Google Sheet via Apps Script.

How to mint one

  1. Open Settings → API in your dashboard.
  2. Click Create token. Give it a label (e.g. "Production CI") so you can tell tokens apart later.
  3. Pick the scopes you need. The default is scans:read only — least privilege. If your script needs to start scans, also tick scans:write.
  4. Set an expiry. The picker defaults to one year from today; pick something shorter for short-lived tokens (e.g. a contractor onboarding window) or clear it for "no expiry".
  5. Hit Create. The plaintext token (sxp_live_…) appears in a yellow banner — copy it now. We hash it before storing, so we can't show it to you again.

Token format

Plaintext tokens look like sxp_live_a3f7q2k8p9d1e6m5n4r3t2x1y0z9w8v7u6s5 — a fixed sxp_live_ prefix plus 32 lowercase base32 characters (no 0/o or 1/l, so it survives screenshot retyping). The literal prefix makes leaked tokens trivially greppable in CI logs and public repos.

We store only the SHA-256 hash and a 13-character display prefix (sxp_live_a3f7). The hashed lookup means a database leak doesn't expose your tokens.

Scopes

Each token carries an explicit list of scopes. Routes that need a scope reject the request with HTTP 403 if the token doesn't have it.

ScopeGrants
scans:readList scans, fetch a single scan's status / results.
scans:writeStart a new scan, cancel an in-progress one.
reports:readDownload a scan report (PDF / CSV / JSON / Markdown / HTML).
domains:readList registered domains.
domains:writeAdd or remove a domain.

Adding a new scope is a non-breaking change — your existing tokens still validate against the old set. New tokens can opt in.

Authenticating a request

Send the token in the Authorization header with the Bearer scheme:

curl -X POST https://seoxpert.io/api/scans \
  -H "Authorization: Bearer sxp_live_a3f7q2k8p9d1e6m5n4r3t2x1y0z9w8v7u6s5" \
  -H "Content-Type: application/json" \
  -d '{
    "rootUrl": "https://example.com",
    "serviceId": "full-scan",
    "crawlMode": "rendered"
  }'

Node.js with fetch:

const res = await fetch('https://seoxpert.io/api/scans', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SEOXPERT_API_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    rootUrl: 'https://example.com',
    serviceId: 'full-scan',
    crawlMode: 'rendered',
  }),
});
const { id } = await res.json();
console.log('Started scan', id);

Rate limits

Each token gets its own rate-limit bucket — one customer's tokens can't starve another's. Limits are enforced per token, not per IP.

PlanLimitSustained
Pro60 requests / minute / token1 req/sec
Agency600 requests / minute / token10 req/sec

When you exceed the limit, the response is HTTP 429 with a JSON body explaining which limit you hit. Wait for the next minute window before retrying.

The plan check happens at request time, not at mint time. If you upgrade Pro → Agency, your existing tokens immediately get the higher limit — no need to re-issue.

Expiry, rotation, and revocation

Tokens carry an optional expiresAt set when you mint them, capped at one year from today. After expiry, the token returns 401 immediately — no grace window. Pick an expiry as long or short as the use case justifies.

To rotate: mint a new token, deploy it, then revoke the old one from the same Settings page. Revocation is instant. We keep the row (with the hash and a revoked_at timestamp) so audit logs can still tie historical requests back to the token.

What happens when you downgrade

If your subscription drops below Pro (e.g. cancelled, downgraded to Free), every API request from your tokens returns 403 immediately. The tokens aren't deleted — re-upgrading restores them without re-issuing.

Endpoint reference

The endpoints API tokens can hit (Bearer auth on each):

Method + pathRequired scopeWhat it does
POST /api/scansscans:writeStart a new scan. Body: {rootUrl, serviceId, crawlMode?}. Returns 200 with the queued scan id, or 402 with {code: 'no_credits'} when out of monthly quota.
GET /api/scansscans:readList scans for the workspace. Query params: ?limit=20, ?customerEmail=….
GET /api/scans/{id}scans:readFetch a single scan's status + summary metadata.
GET /api/scans/{id}/resultsscans:readFull scan results: findings, page explorer, mobile readiness, change-since-last-scan diff.
GET /api/scans/{id}/report?format={json|csv|pdf|markdown|html}reports:readDownload the report. CSV / JSON exports cap at 100 findings on Pro; Agency uncapped (carries X-Seoxpert-Findings-Truncated: true header when clipped).
GET /api/domainsdomains:readList registered domains for the workspace.
POST /api/domainsdomains:writeRegister a new domain. Body: {rootUrl}.
DELETE /api/domains/{id}domains:writeUnregister a domain (does not delete past scans).

Error codes

Every error response carries {error, code?} so clients can branch on a stable identifier instead of parsing the human-readable message.

StatusCodeWhen it fires
401Missing / malformed Bearer header, or the token doesn't exist / is revoked / expired.
403Token is missing the required scope, OR the workspace owner's plan no longer includes API access.
402monthly_limit_reachedScan quota exhausted for the cycle. Upgrade or wait for renewal.
402no_creditsNo active subscription and no remaining trial credit.
403frequency_requires_agencyTried to create a daily / weekdays schedule on Pro.
403workspace_role_required(Dashboard sessions only.) Token-issued requests don't hit role gates.
429Rate-limit exceeded (60/min on Pro, 600/min on Agency).

Use cases — copy, paste, ship

Trigger a scan from your custom dashboard

// Node 18+ — POST a scan, poll until completion, render the report.
const TOKEN = process.env.SEOXPERT_API_TOKEN!;

async function scanAndWait(rootUrl) {
  const start = await fetch('https://seoxpert.io/api/scans', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ rootUrl, serviceId: 'full-scan', crawlMode: 'rendered' }),
  });
  if (!start.ok) throw new Error(`Start failed: ${await start.text()}`);
  const { id } = await start.json();

  // Poll every 5s — average scan completes in 30-90s for a 100-page site.
  for (let i = 0; i < 60; i++) {
    await new Promise(r => setTimeout(r, 5000));
    const s = await fetch(`https://seoxpert.io/api/scans/${id}`, {
      headers: { Authorization: `Bearer ${TOKEN}` },
    });
    const data = await s.json();
    if (data.status === 'completed') return data;
    if (data.status === 'failed') throw new Error(data.errorMessage);
  }
  throw new Error('Scan timed out after 5 minutes');
}

Pull historical scores into a BI tool

List scans, fetch each one's summary, write to your warehouse:

const list = await fetch('https://seoxpert.io/api/scans?limit=100', {
  headers: { Authorization: `Bearer ${TOKEN}` },
}).then(r => r.json());

for (const scan of list.scans) {
  await warehouse.insert('seoxpert_scans', {
    scan_id: scan.id,
    root_url: scan.rootUrl,
    score: scan.overallHealthScore,
    findings: scan.findingsCount,
    pages: scan.pagesCrawled,
    completed_at: scan.completedAt,
  });
}

Embed the latest score in your customer dashboard

// Server-side React Server Component — runs on every page render.
async function LatestScore({ rootUrl }: { rootUrl: string }) {
  const list = await fetch(
    `https://seoxpert.io/api/scans?limit=1`,
    { headers: { Authorization: `Bearer ${process.env.SEOXPERT_API_TOKEN!}` }, cache: 'no-store' },
  ).then(r => r.json());
  const latest = list.scans?.[0];
  if (!latest) return null;
  return (
    <span className="text-2xl font-bold">
      {latest.overallHealthScore}
      <span className="text-xs text-zinc-500 ml-1">/ 100</span>
    </span>
  );
}

Sync your registered domains from a CMDB

Run nightly: pull the canonical list from your asset management system, diff against Seoxpert's registered domains, add/remove to match.

const seoxpert = await fetch('https://seoxpert.io/api/domains', {
  headers: { Authorization: `Bearer ${TOKEN}` },
}).then(r => r.json());

const have = new Set(seoxpert.domains.map(d => d.rootUrl));
const want = new Set(await loadDomainsFromCmdb());

for (const url of want) {
  if (!have.has(url)) {
    await fetch('https://seoxpert.io/api/domains', {
      method: 'POST',
      headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ rootUrl: url }),
    });
  }
}
for (const d of seoxpert.domains) {
  if (!want.has(d.rootUrl)) {
    await fetch(`https://seoxpert.io/api/domains/${d.id}`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${TOKEN}` },
    });
  }
}

Plan availability

API tokens are a Pro and Agency feature. Free-tier users see an upsell card in the API panel. Why not free? Every API call ultimately drives a scan or queries scan results, both of which have real cost; granting unlimited free tokens would let one prankster burn a lot of compute by scripting against us with throwaway accounts.

See the live numbers on the Pricing page. Need a webhook instead of polling? See the webhooks docs — push beats pull every time.