Getting started
Errors & rate limits
Every error comes back in the same JSON envelope, with a machine-readable code to branch on. This page lists every code, the rate limit and its headers, and when a retry is safe.
Error envelope
Any response that isn't 2xx carries one error object. Branch on code; message explains the problem to a person and is the same text you see in the tables below.
{
"error": {
"code": "not_found",
"message": "Site not found."
}
}application/jsonerrorobjectWhat went wrong.errorkeys(2)codestringMachine-readable error code. Branch on this.One of
missing_tokeninvalid_tokenrate_limitednot_foundinvalid_bodyplan_limitinvalid_stateforbiddenfetch_failedupstream_errormessagestringHuman-readable explanation.
500, possibly without the envelope (an HTML page from a proxy, for example). Check the status code before parsing the body, and retry a 500 with backoff.HTTP status codes
| Status | Meaning | Error codes |
|---|---|---|
| 200OK | Success. | None |
| 201Created | Created: POST /sites/{id}/jobs started a job. | None |
| 400Bad Request | The body isn't valid JSON or fails validation. | invalid_body |
| 401Unauthorized | The token is missing, malformed, unknown or revoked. | missing_tokeninvalid_token |
| 403Forbidden | The token is valid but not allowed to do this. | forbidden |
| 404Not Found | The resource doesn't exist, or belongs to another account. | not_found |
| 409Conflict | The job isn't in a state that allows the action. | invalid_state |
| 422Unprocessable Entity | The target URL could not be fetched or parsed (fetch_failed on SEO toolkit endpoints). | fetch_failed |
| 429Too Many Requests | Too many requests for the token, or the plan's AI/article allowance is used up. | rate_limitedplan_limit |
| 500Internal Server Error | Unexpected server error. The body may not use the error envelope. | None |
| 502Bad Gateway | The AI provider failed (upstream_error) — retry the call. | upstream_error |
Error codes
Every code the API returns, the exact messages it sends with it, and what to do.
| Code | Meaning and fix |
|---|---|
missing_token401Unauthorized | The request has no What to do: Send Message
Returned by every endpoint. |
invalid_token401Unauthorized | The token is malformed (it doesn't start with What to do: Check the token for copy-paste mistakes and stray whitespace. If it was revoked or lost, create a new one in Dashboard → API. Messages
Returned by every endpoint. |
rate_limited429Too Many Requests | The token made more than 120 requests in the last 60 seconds. What to do: Wait for the number of seconds in the Message
Returned by every endpoint. |
not_found404Not Found | The site, job, article or saved SEO analysis doesn't exist, or it belongs to another account. The API doesn't reveal which. What to do: Check the ID. List what your token can see with GET /sites, GET /sites/{id}/jobs and GET /seo/analyses. Messages
Returned by GET /sites/{id}, GET /sites/{id}/articles, GET /sites/{id}/articles/{postId}, GET /sites/{id}/jobs, POST /sites/{id}/jobs, GET /jobs/{id}, POST /jobs/{id}/publish, POST /sites/{id}/keywords, GET /seo/analyses/{id}. |
invalid_body400Bad Request | The request body isn't valid JSON or fails validation. What to do: Fix the body as Messages
Returned by POST /sites/{id}/jobs, POST /sites/{id}/keywords, POST /seo/analyze, POST /seo/keywords, POST /seo/article, POST /seo/article-short, POST /seo/meta-tags, POST /seo/open-graph, POST /seo/schema, POST /seo/headings, POST /seo/content-brief, POST /seo/rewrite, POST /seo/alt-texts, POST /seo/robots-sitemap, POST /seo/serp-preview, GET /seo/analyses, PUT /ai/models. |
plan_limit429Too Many Requests | A plan allowance is used up: the site's article allowance (Free: 3 in total; Starter: 8 per calendar month; Pro: 30 per calendar month; failed jobs don't count) when creating a job, or the account's monthly AI-call allowance (Free 300, Starter 1,500, Pro 6,000 AI calls per calendar month) on the SEO toolkit. What to do: Upgrade the plan, or wait for the next calendar month (the Free plan's article allowance never resets). This isn't a rate limit: there's no Messages
Returned by POST /sites/{id}/jobs, POST /seo/analyze, POST /seo/keywords, POST /seo/article, POST /seo/article-short, POST /seo/meta-tags, POST /seo/open-graph, POST /seo/schema, POST /seo/headings, POST /seo/content-brief, POST /seo/rewrite, POST /seo/alt-texts. |
invalid_state409Conflict | The job can't be published in its current state. Only jobs in What to do: Check Message
Returned by POST /jobs/{id}/publish. |
forbidden403Forbidden | The token is valid but not allowed to do this: only admin tokens can change AI model settings. What to do: Read the configuration with GET /ai/models. Changes are made by Autoranko administrators. Message
Returned by PUT /ai/models. |
fetch_failed422Unprocessable Entity | An SEO toolkit endpoint couldn't fetch or read the page at What to do: Check that the URL is public and returns an HTML page with a status below 400, for example by opening it in a private browser window. A Messages
Returned by POST /seo/analyze, POST /seo/keywords, POST /seo/meta-tags, POST /seo/open-graph, POST /seo/schema, POST /seo/headings, POST /seo/alt-texts. |
upstream_error502Bad Gateway | The AI provider failed or returned an unusable result during a synchronous AI request: keyword research or an SEO toolkit endpoint. Nothing was saved. What to do: Retry after a few seconds, backing off exponentially if it keeps failing. On the SEO toolkit, AI calls that completed before the failure still count toward the monthly allowance. Messages
Returned by POST /sites/{id}/keywords, POST /seo/analyze, POST /seo/keywords, POST /seo/article, POST /seo/article-short, POST /seo/meta-tags, POST /seo/open-graph, POST /seo/schema, POST /seo/headings, POST /seo/content-brief, POST /seo/rewrite, POST /seo/alt-texts. |
Rate limits
- requests per token
- 120
- sliding window
- 60 s
- Retry-After when limited
- 30 s
- The limit is 120 requests per token over a sliding 60-second window. Each token has its own budget.
- The
X-RateLimit-*headers come with every successful response and with 429 rate_limited. Other error responses don't include them, except on the SEO toolkit (/seo/…), where every error after authentication does. - There's no remaining-requests header. Keep to about 2 requests per second on average, and when you get 429 rate_limited, wait
Retry-Afterseconds before retrying. - Requests rejected with
401don't count. Every authenticated request does, including ones that fail with400or404. - Running out of articles is a different error, 429 plan_limit, and waiting won't fix it.
Headers
| Header | Example | Description |
|---|---|---|
X-RateLimit-Limit | 120 | Requests allowed per window for this token. |
X-RateLimit-Window | 60s | Length of the sliding window the limit is counted over. |
X-RateLimit-Scope | token-6 | The bucket the request was counted against: one per API token (the number is the token's internal ID). |
Retry-After | 30 | Sent only with 429 rate_limited: seconds to wait before retrying. |
What they look like on the wire (header names arrive in lowercase over HTTP/2):
HTTP/2 200
content-type: application/json
x-ratelimit-limit: 120
x-ratelimit-scope: token-6
x-ratelimit-window: 60sHTTP/2 429
content-type: application/json
retry-after: 30
x-ratelimit-limit: 120
x-ratelimit-scope: token-6
x-ratelimit-window: 60s
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded (120 requests/minute)."
}
}Two different 429s
Retry-After seconds. 429 plan_limit means the site has used its article allowance; it has no Retry-After, and retrying won't help until the allowance resets or the plan changes.Retries and idempotency
Retry when the request didn't take effect, or when repeating it can't do harm.
| Response | Retry? | How |
|---|---|---|
| 429 rate_limited | Yes | After the Retry-After seconds (30). The request was turned away before it did anything. |
| 502 upstream_error | Yes | After a few seconds, doubling the wait each time. Only keyword research returns it, and nothing was saved. |
500, a timeout or a network error | Depends | Safe for GET requests, PUT /ai/models and POST /jobs/{id}/publish. Keyword research is safe too, though each request counts as an AI call. For POST /sites/{id}/jobs, check before retrying (below). |
| 409 invalid_state | No | Check the job with GET /jobs/{id}. After a retried publish, it means the first call went through. |
| 429 plan_limit | No | Not until the allowance resets (next calendar month on paid plans) or the plan is upgraded. |
| 400 invalid_body401 invalid_token403 forbidden404 not_found | No | The same request fails the same way. Fix the body, token or ID first. |
Creating jobs isn't idempotent
POST /sites/{id}/jobs has no idempotency key: every call that reaches the server creates a job and uses an article from the allowance. If a create request times out, list the site's newest jobs with GET /sites/{id}/jobs and look for your topic before you send it again.
Publishing is safe to retry
POST /jobs/{id}/publish only acts on a job in review. If the call times out, send it again: when the first call went through, the job is already publishing or published and the retry gets 409 invalid_state instead of creating a second post. Treat that 409 as success and confirm with GET /jobs/{id}. Don't send publish calls for the same job in parallel.
Webhook deliveries can repeat
A webhook can arrive twice when a retry overlaps a slow response. Make handlers idempotent: a repeated delivery has the same event, data.id and created_at. Delivery rules
A retry helper
A small wrapper that retries the two errors that are always safe to retry, 429 rate_limited and 502 upstream_error, and throws everything else so your code can decide.
# --retry waits for Retry-After on a 429 and backs off on 5xx responses and timeouts.
# Use it for GET requests: a retried POST /sites/{id}/jobs could create a second job.
curl --retry 4 https://autoranko.com/api/public/v1/sites \
-H "Authorization: Bearer $AUTORANKO_TOKEN"const API = "https://autoranko.com/api/public/v1";
// Retries the two errors that mean the request wasn't processed:
// 429 rate_limited (after Retry-After) and 502 upstream_error (with backoff).
async function autoranko(method, path, body, attempt = 1) {
const res = await fetch(API + path, {
method,
headers: {
Authorization: `Bearer ${process.env.AUTORANKO_TOKEN}`,
"Content-Type": "application/json",
},
body: body && JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
const code = data.error?.code;
if ((code === "rate_limited" || code === "upstream_error") && attempt < 5) {
const seconds = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
return autoranko(method, path, body, attempt + 1);
}
if (!res.ok) throw new Error(`${code ?? res.status}: ${data.error?.message ?? res.statusText}`);
return data;
}
const { sites } = await autoranko("GET", "/sites");
console.log(sites.length, "site(s)");import os
import time
import requests
API = "https://autoranko.com/api/public/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['AUTORANKO_TOKEN']}"
def autoranko(method, path, body=None, attempts=5):
# Retries the two errors that mean the request wasn't processed:
# 429 rate_limited (after Retry-After) and 502 upstream_error (with backoff).
for attempt in range(1, attempts + 1):
res = session.request(method, API + path, json=body, timeout=130)
try:
data = res.json()
except ValueError:
data = {}
error = data.get("error") or {}
if error.get("code") in ("rate_limited", "upstream_error") and attempt < attempts:
time.sleep(float(res.headers.get("Retry-After", 2 ** attempt)))
continue
if not res.ok:
raise RuntimeError(f"{error.get('code', res.status_code)}: {error.get('message', res.reason)}")
return data
sites = autoranko("GET", "/sites")["sites"]
print(len(sites), "site(s)")<?php
// Retries the two errors that mean the request wasn't processed:
// 429 rate_limited (after Retry-After) and 502 upstream_error (with backoff).
function autoranko(string $method, string $path, ?array $body = null, int $attempts = 5): array
{
for ($attempt = 1; ; $attempt++) {
$retryAfter = 0;
$ch = curl_init('https://autoranko.com/api/public/v1' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => 130,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('AUTORANKO_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_HEADERFUNCTION => function ($handle, $line) use (&$retryAfter) {
if (stripos($line, 'Retry-After:') === 0) $retryAfter = (int) substr($line, 12);
return strlen($line);
},
]);
if ($method !== 'GET') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body === null ? '' : json_encode($body));
}
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException('Network error: ' . curl_error($ch));
$data = json_decode($raw, true) ?: [];
$code = $data['error']['code'] ?? null;
if (in_array($code, ['rate_limited', 'upstream_error'], true) && $attempt < $attempts) {
sleep($retryAfter ?: 2 ** $attempt);
continue;
}
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) >= 400) {
throw new RuntimeException(($code ?? 'http_error') . ': ' . ($data['error']['message'] ?? 'Request failed'));
}
return $data;
}
}
$sites = autoranko('GET', '/sites')['sites'];
echo count($sites), " site(s)\n";