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/v1

Every 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_here

A 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"
Response
{
  "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#

WindowLimitScope
Per minute120 requestsper API key
Per day10,000 requestsper API key

Every response carries the current state of both windows:

X-RateLimit-Limit:     120
X-RateLimit-Remaining: 117
X-RateLimit-Reset:     1789203600

Over 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"
  }
}
FieldMeans
search_console_connectedTrue once a Search Console property is connected. Every window fills in from the day it starts reporting.
throughThe 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_28dClicks, 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_deltaLower 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_clickComplete 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.
citationsCitations 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.inconclusiveTrue 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_referralsAlways null. Referral sessions need a web analytics property and Rankli connects none, so `ai_referrals_reason` carries that sentence instead of a fabricated number.
The 90-day daily series is not repeated on every article row. Use 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"
  }
}
StatusMeans
400The body or the query string is wrong. `field` names the offending one.
401Missing, malformed, expired or revoked key.
402The action would exceed the plan’s article allowance.
404No such resource, or it belongs to another account.
409The resource is in a state that forbids this (already publishing, already deleted).
429Rate limited. Honour `Retry-After`.
5xxOurs. 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.

  1. Install the CLI: npm install -g rankli-cli
  2. Export the key: export RANKLI_API_KEY=rk_live_…
  3. Point your agent at it. rankli-cli --help lists every command, and each one mirrors an endpoint above.

The CLI reference has the full command list.