Platform
Rankli REST API
Everything the app does to your content, your own code can do. Create a product, generate keywords, pre-create next week’s articles, pull the markdown, retry a failed publish.
Getting a key#
The REST API comes with the Agency plan. Full-scope keys are created on Agency; the blog-scoped key the Next.js blog integration issues works on every plan.
Keys are created in the app: open Settings → API keys and click Create key. The full key is shown once, at creation. Copy it then: only a hash is stored, so the recovery path is to revoke the key and create another.
A key is scoped to one account and carries that account’s permissions. Keys are prefixed rk_live_ so one can be spotted in a log or a commit and revoked.
Treat a key like a password. It can generate articles, which spends your monthly allowance, and it can publish to your live site. Keep it in an environment variable, never in a repository, and revoke it the moment it appears in one.
Base URL#
https://www.rankli.io/api/v1Every path below is relative to that base, GET /products means GET https://www.rankli.io/api/v1/products. Requests and responses are JSON; send Content-Type: application/json on anything with a body.
Authentication#
Send the key as a bearer token on every request.
Authorization: Bearer rk_live_your_api_key_hereA missing, malformed, expired or revoked key answers 401. A key belonging to a different account than the resource answers 404 rather than 403, because confirming that an id exists in somebody else’s account is itself a leak.
Quickstart#
Confirm the key works before writing anything against it:
curl https://www.rankli.io/api/v1/auth/whoami \
-H "Authorization: Bearer rk_live_your_api_key_here"{
"user": { "id": "usr_...", "email": "you@example.com" },
"account": { "id": "acc_...", "name": "Acme", "plan": "prove", "product_count": 2 },
"credits": { "articles_remaining": 17, "period_ends_at": "2026-10-01T00:00:00Z" }
}Rate limits#
| Window | Limit | Scope |
|---|---|---|
| Per minute | 120 requests | per API key |
| Per day | 10,000 requests | per API key |
Every response carries the current state of both windows:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1789203600Over the limit you get 429 with a Retry-After header in seconds. Honour it, retrying inside the window costs a request from the next one.
Article generation is bounded by your plan’s monthly allowance, not by the rate limit. A POST /articles/generate that would exceed it answers 402 with the remaining count in the body.
Endpoints#
Auth
GET/auth/whoami
The user and account this key belongs to, the plan, and the article credits left this period. The cheapest call to make in a health check.
Products
A product is one website: its profile, its keywords, its calendar and its integrations.
GET/products
Every product on the account, with its domain, plan slot and pause state.
POST/products
Create one from a domain. Body: { "domain": "acme.com", "name": "Acme" }. Creating a product reads the site, researches keywords and queues a 30-day plan, the same three stages onboarding runs.
Keywords
GET/keywords?product_id=...
The keyword rows for a product: phrase, intent, cluster, scheduled date, and volume and difficulty when a data provider is configured. Without one those two fields are null, never 0. See nulls below.
POST/keywords/generate
Research more phrases for a product. Body: { "product_id": "...", "count": 40 }.
POST/keywords/bulk-reschedule
Move a set of keywords onto new dates. Body: { "ids": [...], "start_date": "2026-10-01" }.
POST/keywords/bulk-delete
Remove keywords that have not been written yet. Body: { "ids": [...] }.
Articles
GET/articles?product_id=...&status=...
Article rows: id, title, slug, keyword, status, scheduled date, publish state, the check score and metrics, the receipts. Paginated with limit and cursor.
GET/articles/{id}
One article with its metadata, its SEO report, its publish attempts and the same metrics object.
GET/articles/{id}/content?format=markdown
The body itself. format is markdown or html; markdown is the default.
POST/articles/precreate
Write an article scheduled for a future date, now, so you can read it before it publishes. Body: { "keyword_id": "..." }. Spends one from the allowance.
POST/articles/generate
Generate the article for a keyword due today. Body: { "keyword_id": "..." }.
POST/articles/{id}/retry-publish
Re-run a publish that failed. Publishing is idempotent: a retry updates the post it already created rather than making a second one.
Subscription and usage
GET/subscription/status
Plan, period, seats and whether the subscription is active.
GET/usage/stats
Articles produced this period, allowance, and spend on model calls.
Receipts: the metrics object#
Both article endpoints carry a metrics object: what the piece actually earned once it was live. It is null for an article that has not been published, there is no live URL for Search Console to have reported on, and the fields inside it are null wherever the measurement is missing.
{
"id": "9f1c…", "title": "How to choose a field recorder", "status": "published",
"published_url": "https://acme.com/blog/choose-a-field-recorder",
"metrics": {
"search_console_connected": true,
"synced_through": "2026-09-03", "through": "2026-09-03", "reporting_lag_days": 3,
"published_on": "2026-07-21", "first_reportable_day": "2026-07-24",
"days_since_publish": 44,
"first_impression_on": "2026-07-29", "days_to_first_impression": 8,
"first_click_on": "2026-08-14", "days_to_first_click": 24,
"window_7d": { "measured": true, "start": "2026-08-28", "end": "2026-09-03",
"clicks": 6, "impressions": 412, "position": 14.2,
"clicks_delta": 2, "impressions_delta": 88, "position_delta": -1.6 },
"window_28d": { "measured": true, "start": "2026-08-07", "end": "2026-09-03",
"clicks": 19, "impressions": 1480, "position": 16.1,
"clicks_delta": 11, "impressions_delta": 903, "position_delta": -3.4 },
"top_queries": [{ "query": "best field recorder", "clicks": 4, "impressions": 210, "position": 11.8 }],
"citations": { "round_label": "Sept round", "finished_at": "2026-09-01", "mode": "live",
"answers_sampled": 24, "last_round": 2, "total": 5,
"providers": ["anthropic", "openai"],
"inconclusive": false, "reason": "" },
"summary": { "impressions_28d": 1480, "clicks_28d": 19, "position_28d": 16.1,
"impressions_28d_delta": 903, "clicks_28d_delta": 11,
"position_28d_delta": -3.4, "citations_last_round": 2 },
"ai_referrals": null,
"ai_referrals_reason": "Not tracked yet: needs a web analytics connection"
}
}| Field | Means |
|---|---|
| search_console_connected | True once a Search Console property is connected. Every window fills in from the day it starts reporting. |
| through | The last complete day the windows end at: today minus `reporting_lag_days`. Google does not finalise the last few days, so we do not report them. |
| window_7d / window_28d | Clicks, impressions and impression-weighted average position over that many complete days, with `_delta` against the equally long window immediately before. `measured: false` means no day in the window is covered by a sync, and every figure in it is then null. |
| position / position_delta | Lower is better. A negative `position_delta` is an improvement. Null when the window had no impressions to weight, never 0, which would read as ranking above the first result. |
| days_to_first_impression / days_to_first_click | Complete days from publication to the first day Google reported an impression, and a click. Null until it has happened; we do not report a running total as though it were an outcome. |
| citations | Citations of this exact URL in AI answers. `last_round` is the most recent finished round, `total` every round so far, `providers` the engines that cited it. Null when no round has finished for this product. |
| citations.inconclusive | True when the round cannot carry a claim: too few answers were sampled, or the interval includes zero. `reason` says which. Read the counts only when this is false. |
| ai_referrals | Always null. Referral sessions need a web analytics property and Rankli connects none, so `ai_referrals_reason` carries that sentence instead of a fabricated number. |
GET /products/{id}/gsc/performance?days=90 for the whole property, day by day.Nulls are not zeros#
volume, difficulty and cpc are null until a paid search-data account is connected, and they carry real readings once it is. No number is ever stood in for a measurement.
This matters more through the API than in the UI, because your code will do arithmetic on these. A 0 where a measurement is missing looks like a real reading of “nobody searches for this” and quietly demotes the keyword out of your plan. Check for null before you sort.
const ordered = keywords
.filter((k) => k.volume !== null) // not `k.volume > 0`
.sort((a, b) => b.volume - a.volume);The same rule runs through metrics: an unmeasured window, an average position with no impressions to weight, a click that has not happened yet and AI referrals, which nothing measures at all, are all null. A published article with impressions_28d: 0 was shown to nobody; one with impressions_28d: null was not measured, and the two must not average together.
Errors#
{
"error": {
"code": "keyword_not_due",
"message": "That keyword is scheduled for 2026-10-14. Use /articles/precreate to write it now.",
"field": "keyword_id"
}
}| Status | Means |
|---|---|
| 400 | The body or the query string is wrong. `field` names the offending one. |
| 401 | Missing, malformed, expired or revoked key. |
| 402 | The action would exceed the plan’s article allowance. |
| 404 | No such resource, or it belongs to another account. |
| 409 | The resource is in a state that forbids this (already publishing, already deleted). |
| 429 | Rate limited. Honour `Retry-After`. |
| 5xx | Ours. Safe to retry idempotent calls with backoff. |
Driving it from an AI agent#
The API is designed to be driven by a coding agent as much as by a script, every response is JSON, every error says what to do next, and the CLI wraps the whole surface with a --json flag.
- Install the CLI:
npm install -g rankli-cli - Export the key:
export RANKLI_API_KEY=rk_live_… - Point your agent at it.
rankli-cli --helplists every command, and each one mirrors an endpoint above.
The CLI reference has the full command list.