Ranknera for developers
Connect Ranknera to the tools you already use. A REST API for scripts and partners, an MCP server so AI assistants like Claude and ChatGPT can work with your keywords and articles, and signed webhooks that tell your systems when an article is drafted or published.
Getting started
- In the dashboard, open Settings › Integrations › API keys and create a key with the permissions it needs. The key is shown once: store it in your secret manager.
- Send it on every request:
Authorization: Bearer rn_live_… - The API answers at
https://api.ranknera.com/api/v1; the MCP server athttps://api.ranknera.com/mcp.
Keys start with rn_live_ (live) or rn_test_ (test). A test key works on a separate sandbox account with an example site: written articles are samples and nothing reaches WordPress or a paid service, so you can build your integration without spending your allowance. Topic plans, gap searches and ranking checks have no test version and answer test_mode_unsupported.
#!/bin/sh
# List your sites with curl. RANKNERA_API_KEY is your key (rn_live_… or rn_test_…).
curl -sS "${RANKNERA_API_URL:-https://api.ranknera.com}/api/v1/sites" \
-H "Authorization: Bearer $RANKNERA_API_KEY"Keys and permissions
Each key or connection gets permissions one by one, as resource:operation. Anything not granted is refused with permission_missing, naming the permission. A key never does more than its account can do now: on the free plan keys can only read, and Autopilot, competitors and topic plans need the Autopilot plan. Changing a plan or a key takes effect on the next request. Rotating a key keeps the old one working for an overlap you choose (24 hours by default); revoking stops it at once.
sites:read | Read your sites |
sites:create | Add sites |
sites:update | Change site details |
sites:delete | Remove sites from Ranknera (WordPress is not touched) (sensitive) |
keywords:read | Read keywords |
keywords:create | Add keywords |
keywords:update | Change keywords |
keywords:delete | Delete keywords (sensitive) |
keywords:bulk | Change many keywords at once (sensitive) |
articles:read | Read articles and runs |
articles:update | Edit articles waiting for review |
articles:delete | Delete or discard articles (sensitive) |
articles:write | Write new articles (uses your monthly allowance) (sensitive) |
articles:publish | Publish or schedule articles on your WordPress site (sensitive) |
plans:read | Read topic plans and competitor gaps |
plans:create | Make new topic plans and gap searches (uses research credits) (sensitive) |
competitors:read | Read competitors |
competitors:create | Add competitors |
competitors:update | Change competitors |
competitors:delete | Remove competitors (sensitive) |
competitors:bulk | Change many competitors at once (sensitive) |
autopilot:read | Read Autopilot settings |
autopilot:update | Change Autopilot settings |
performance:read | Read ranking checks |
performance:execute | Run a ranking check now (sensitive) |
webhooks:manage | Manage webhook subscriptions (sensitive) |
REST API
JSON in and out, snake_case fields. Lists return {data, next_cursor, has_more}: pass cursor=next_cursor for the next page (at most 100 items). Writes accept an Idempotency-Key header: the same key and body within 24 hours return the first answer instead of doing it twice. Another account's ids answer 404.
// Add a keyword to a site's queue, with Node's fetch. RANKNERA_API_KEY is your key; SITE_ID a site from GET /api/v1/sites.
// The Idempotency-Key makes a retry after a lost answer safe: the keyword is added once.
const base = process.env.RANKNERA_API_URL ?? "https://api.ranknera.com";
const res = await fetch(`${base}/api/v1/keywords`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RANKNERA_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ site_id: Number(process.env.SITE_ID), keyword: "best trail running shoes" }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
console.log(body.id, body.keyword, body.status);"""List your articles waiting for review, with Python's standard library.
RANKNERA_API_KEY is your key (rn_live_… or rn_test_…)."""
import json
import os
import urllib.request
BASE = os.environ.get("RANKNERA_API_URL", "https://api.ranknera.com")
req = urllib.request.Request(f"{BASE}/api/v1/articles?status=manual_review&limit=20",
headers={"Authorization": f"Bearer {os.environ['RANKNERA_API_KEY']}"})
with urllib.request.urlopen(req) as res:
page = json.load(res)
for article in page["data"]:
print(article["id"], article["status"], article["keyword"])| Method and path | Permission | What it does |
|---|---|---|
GET /sites | sites:read | Your WordPress sites (never their application passwords). |
GET /sites/{site_id} | sites:read | One site. |
POST /sites | sites:create | Add a site: Ranknera checks the WordPress connection first. |
PATCH /sites/{site_id} | sites:update | Change a site's details. |
DELETE /sites/{site_id} | sites:delete | Remove a site from Ranknera, with its keywords and history. WordPress is not touched. |
POST /sites/{site_id}/test | sites:read | Check the site's WordPress connection now. |
GET /keywords | keywords:read | Keywords, newest first; filter by site, status and source. |
GET /keywords/{keyword_id} | keywords:read | One keyword. |
POST /keywords | keywords:create | Add a keyword to a site's queue. |
PATCH /keywords/{keyword_id} | keywords:update | Change a keyword's text while nothing has been written for it. |
DELETE /keywords/{keyword_id} | keywords:delete | Delete a keyword. |
POST /keywords/bulk | keywords:bulk keywords:create | Add up to 100 keywords at once; existing ones are skipped and reported. |
POST /keywords/bulk-update | keywords:bulk | Reset (keywords:update) or delete (keywords:delete) up to 100 keywords; all must be yours, or nothing changes. |
GET /articles | articles:read | Articles and runs, newest first, without their text. |
GET /articles/{article_id} | articles:read | One article with its title, meta description and text (and each piece of a topic cluster). |
POST /articles | articles:write | Write an article for a keyword (one of this month's articles; more for a topic cluster). Answers with the queued run. |
PATCH /articles/{article_id} | articles:update | Edit an article waiting for your review (drafts already in WordPress are edited there). |
DELETE /articles/{article_id} | articles:delete | Delete an article from history (WordPress is not touched). |
POST /articles/{article_id}/discard | articles:delete | Discard an article waiting for review; its keyword is marked discarded. |
POST /articles/{article_id}/approve | articles:publish | Approve an article waiting for review and publish it to WordPress (a piece of a cluster with `piece`). |
POST /articles/{article_id}/publish | articles:publish | Publish again an article whose publishing failed. |
POST /articles/{article_id}/schedule | articles:publish | Schedule a WordPress draft to go live at a time. |
GET /usage | articles:read | This month's article allowance: plan, limit, used, remaining. |
GET /sites/{site_id}/plan | plans:read | The site's latest topic plan. |
POST /plans | plans:create | Make a topic plan for a site (uses research credits; can take a minute). |
GET /sites/{site_id}/competitor-gaps | plans:read | The site's latest competitor gap search. |
POST /competitor-gaps | plans:create | Find keywords your competitors rank for and you don't (uses research credits). |
GET /sites/{site_id}/competitors | competitors:read | The site's competitors. |
POST /sites/{site_id}/competitors | competitors:create | Add a competitor by domain. |
POST /sites/{site_id}/competitors/bulk | competitors:bulk competitors:create | Add up to 100 competitors at once. |
PATCH /sites/{site_id}/competitors/{competitor_id} | competitors:update | Change a competitor's label, watch or priority. |
DELETE /sites/{site_id}/competitors/{competitor_id} | competitors:delete | Remove a competitor. |
GET /sites/{site_id}/autopilot | autopilot:read | The site's Autopilot settings. |
PATCH /sites/{site_id}/autopilot | autopilot:update | Change Autopilot settings (the same bounds as the app). |
GET /performance | performance:read | Ranking checks of published articles, newest first. |
POST /performance/check | performance:execute | Check rankings in Google Search Console now (can take a minute). |
GET /events | articles:read keywords:read sites:read plans:read performance:read autopilot:read (any) | Your events from the last 90 days, oldest first, by cursor (the same payloads as webhooks). |
GET /webhooks | webhooks:manage | Your webhooks in this key's mode. |
POST /webhooks | webhooks:manage | Add a webhook; the answer carries its signing secret once. |
PATCH /webhooks/{sub_id} | webhooks:manage | Change a webhook, or turn it on or off (on again: new events only). |
DELETE /webhooks/{sub_id} | webhooks:manage | Delete a webhook; its pending deliveries stop. |
POST /webhooks/{sub_id}/rotate-secret | webhooks:manage | A new signing secret, shown once; the old one signs too for 24 hours. |
POST /webhooks/{sub_id}/test | webhooks:manage | Send a webhook.test event to this webhook. |
GET /webhooks/{sub_id}/deliveries | webhooks:manage | The delivery log of the last 30 days, newest first. |
POST /webhooks/{sub_id}/deliveries/{delivery_id}/retry | webhooks:manage | Send a failed delivery again (within 7 days), with the same webhook-id. |
Errors
Errors are RFC 9457 problem details: {type, title, status, code, detail}, with permission on a 403 and a Retry-After header on a 429. Act on code; the words in detail may change.
| Code | Status | Meaning |
|---|---|---|
missing_credentials | 401 | No API key or access token was sent. Send Authorization: Bearer <key or token>. |
invalid_key | 401 | The key isn't a Ranknera key, or it was deleted. |
key_revoked | 401 | The key was revoked. Make a new one in Settings > Integrations. |
key_disabled | 401 | The key is turned off. Turn it on again in Settings > Integrations. |
key_expired | 401 | The key passed its expiry date. Make a new one. |
invalid_token | 401 | The OAuth access token is wrong, expired or revoked. Refresh it, or connect again. |
account_inactive | 401 | The account the credential belongs to is disabled or deleted. |
permission_missing | 403 | The credential doesn't have the permission named in `permission`. |
not_found | 404 | No such record, or it belongs to another account. |
validation_failed | 422 | The request doesn't fit: `detail` names the field. |
conflict | 409 | The record isn't in a state that allows this (for example, editing a published article). |
plan_required | 402 | Your plan doesn't include this. |
idempotency_key_reused | 422 | This Idempotency-Key was used for a different request. |
invalid_idempotency_key | 400 | The Idempotency-Key is longer than 200 characters. |
invalid_cursor | 400 | The cursor isn't one Ranknera gave out. Start again without it. |
test_mode_unsupported | 409 | This uses WordPress or a paid service, so a test key can't do it. Use a live key. |
rate_limited | 429 | Too many requests. Wait for the seconds in Retry-After. |
too_many_auth_failures | 429 | Too many failed sign-ins from your address. Wait for the seconds in Retry-After. |
body_too_large | 413 | The request is larger than 1 MB. |
confirmation_required | MCP | MCP only: call the tool's _preview first and pass its confirmation_token. |
too_many_records | MCP | MCP only: one call can touch at most 100 records. |
invalid_input | MCP | MCP only: the arguments don't fit the tool's input schema. |
MCP for AI assistants
Ranknera runs a remote MCP server at https://api.ranknera.com/mcp (Streamable HTTP). Assistants connect by URL and sign in with OAuth: you see the permissions they ask for in plain words, keep the ones you want, and can disconnect them any time in Settings › Integrations › MCP. Tools that can't do OAuth can send an API key as Authorization: Bearer.
- Claude and ChatGPT: add a custom connector with the URL above.
- Claude Code:
claude mcp add --transport http ranknera https://api.ranknera.com/mcp - Cursor: in
mcp.json,{"mcpServers": {"ranknera": {"url": "https://api.ranknera.com/mcp"}}}
Tools that publish, delete, spend your monthly allowance or change many records come in pairs: name_preview shows what would happen and returns a confirmation_token; name does it, given that token within 5 minutes, once. Text your account wrote (keywords, article titles and bodies) comes back as {"source": "user_content", "content": …}: assistants should treat it as data, never as instructions. One call touches at most 100 records; lists return at most 50 items a page.
| Tool | Permission | Kind | What it does |
|---|---|---|---|
sites_list | sites:read | read-only | Lists your WordPress sites in Ranknera, with their connection status. |
sites_get | sites:read | read-only | Gets one site by id. |
keywords_list | keywords:read | read-only | Lists keywords in the writing queue, newest first. Filter by site and status (pending, running, published, failed). |
keywords_get | keywords:read | read-only | Gets one keyword by id. |
keywords_create | keywords:create | changes data | Adds one keyword to a site's writing queue. Nothing is written until a run picks it up. |
keywords_update | keywords:update | changes data | Changes a keyword's text while nothing has been written for it. |
keywords_delete | keywords:delete | confirm first | Deletes a keyword from the queue. |
keywords_bulk_create | keywords:bulk keywords:create | confirm first | Adds up to 100 keywords to a site's queue at once; ones already there are skipped. |
articles_list | articles:read | read-only | Lists articles and runs, newest first, without their text. Filter by site and status (manual_review, draft_created_wordpress, published_live_wordpress, scheduled, failed). |
articles_get | articles:read | read-only | Gets one article with its title, meta description and text, and its scores and WordPress status. |
articles_update | articles:update | changes data | Edits the title, meta description or text of an article waiting for review. Drafts already in WordPress are edited there. |
articles_write | articles:write | confirm first | Starts writing an article for a keyword on a site. It uses one of this month's articles (more for a topic cluster) and takes several minutes. |
articles_publish | articles:publish | confirm first | Publishes an article on its WordPress site: approves one waiting for review, publishes again one that failed, or schedules it with publish_at. |
articles_discard | articles:delete | confirm first | Discards an article waiting for review; its keyword is marked discarded. |
usage_get | articles:read | read-only | Gets the plan, this month's article limit, how many were used and how many are left. |
plans_get | plans:read | read-only | Gets a site's latest topic plan (clusters of keywords to write). |
competitor_gaps_get | plans:read | read-only | Gets a site's latest competitor gap search: keywords competitors rank for and the site doesn't. |
plans_create | plans:create | confirm first | Makes a new topic plan for a site from its competitors and topics. Uses research credits and can take a minute. |
competitors_list | competitors:read | read-only | Lists a site's competitor domains. |
competitors_create | competitors:create | changes data | Adds a competitor domain to a site. |
competitors_delete | competitors:delete | confirm first | Removes a competitor from a site. |
autopilot_get | autopilot:read | read-only | Gets a site's Autopilot settings: on or off, how much it writes and how often. |
autopilot_update | autopilot:update | changes data | Changes a site's Autopilot settings, within the same bounds as the app. |
performance_list | performance:read | read-only | Lists ranking checks of published articles (position, clicks, whether a refresh was triggered), newest first. |
performance_check | performance:execute | confirm first | Checks a site's rankings in Google Search Console now. Can take a minute. |
Webhooks
Delivery is at least once. The same event can arrive more than once (a retry after a lost answer, a resend from the log): deduplicate on the webhook-id header. Events can also arrive out of order: each carries data.version, which rises with every change to a resource, so ignore one older than what you hold, or fetch the resource through the API.
Add webhooks in Settings › Integrations › Webhooks, or with webhooks:manage through the API. Each has its own address (public https; plain http only for test-mode webhooks), its events and a signing secret shown once. Webhooks follow Standard Webhooks: the webhook-id, webhook-timestamp and webhook-signature headers, an HMAC-SHA256 signature over id.timestamp.body. Refuse a timestamp more than 5 minutes from your clock. After a secret is rotated, both signatures are sent for 24 hours.
{
"id": "evt_3f1c…",
"type": "article.published",
"timestamp": "2026-10-24T09:30:00.000Z",
"schema_version": 1,
"mode": "live",
"data": {
"object": "article",
"id": "184",
"version": 1792833000123456,
"detail": { "site_id": 3, "keyword": "best trail running shoes", "url": "https://example.com/best-trail-running-shoes" }
}
}// Verify a Ranknera webhook (Standard Webhooks) in Node, with no dependencies.
// secret: your webhook's signing secret (whsec_…); headers: the request's headers; body: the raw body text.
const crypto = require("node:crypto");
function verifyWebhook(secret, headers, body) {
const id = headers["webhook-id"], timestamp = headers["webhook-timestamp"], signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // older than 5 minutes: refuse
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
return signatures.split(" ").some((s) => {
const [version, sig] = s.split(",");
return version === "v1" && sig && sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
}
// For the docs' test: reads the body on stdin and the rest from the environment.
if (require.main === module) {
let body = "";
process.stdin.on("data", (c) => (body += c)).on("end", () => {
const e = process.env;
const ok = verifyWebhook(e.SECRET, { "webhook-id": e.WEBHOOK_ID, "webhook-timestamp": e.WEBHOOK_TIMESTAMP, "webhook-signature": e.WEBHOOK_SIGNATURE }, body);
console.log(ok ? "valid" : "invalid");
});
}
module.exports = { verifyWebhook };"""Verify a Ranknera webhook (Standard Webhooks) in Python, standard library only.
secret: your webhook's signing secret (whsec_…); headers: the request's headers; body: the raw body bytes."""
import base64
import hashlib
import hmac
import os
import sys
import time
def verify_webhook(secret: str, headers: dict, body: bytes) -> bool:
wid, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
if not (wid and ts and sigs):
return False
try:
if abs(time.time() - int(ts)) > 300: # older than 5 minutes: refuse
return False
except ValueError:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{wid}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
return any(v == "v1" and hmac.compare_digest(s, expected) for v, _, s in (p.partition(",") for p in sigs.split(" ")))
if __name__ == "__main__": # for the docs' test: body on stdin, the rest from the environment
e = os.environ
ok = verify_webhook(e["SECRET"], {"webhook-id": e["WEBHOOK_ID"], "webhook-timestamp": e["WEBHOOK_TIMESTAMP"],
"webhook-signature": e["WEBHOOK_SIGNATURE"]}, sys.stdin.buffer.read())
print("valid" if ok else "invalid")// Verify a Ranknera webhook (Standard Webhooks) in Go, standard library only.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"math"
"os"
"strconv"
"strings"
"time"
)
// VerifyWebhook: secret is your signing secret (whsec_…); id, timestamp and signatures are the
// webhook-id, webhook-timestamp and webhook-signature headers; body is the raw body.
func VerifyWebhook(secret, id, timestamp, signatures string, body []byte) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if id == "" || signatures == "" || err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, s := range strings.Split(signatures, " ") {
parts := strings.SplitN(s, ",", 2)
if len(parts) == 2 && parts[0] == "v1" && hmac.Equal([]byte(parts[1]), []byte(expected)) {
return true
}
}
return false
}
// For the docs' test: body on stdin, the rest from the environment.
func main() {
body, _ := io.ReadAll(os.Stdin)
if VerifyWebhook(os.Getenv("SECRET"), os.Getenv("WEBHOOK_ID"), os.Getenv("WEBHOOK_TIMESTAMP"), os.Getenv("WEBHOOK_SIGNATURE"), body) {
fmt.Println("valid")
} else {
fmt.Println("invalid")
}
}<?php
// Verify a Ranknera webhook (Standard Webhooks) in PHP, no dependencies.
// $secret: your signing secret (whsec_…); $headers: the request's headers (lower-case names); $body: the raw body.
function verify_webhook(string $secret, array $headers, string $body): bool {
$id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? ''; $sigs = $headers['webhook-signature'] ?? '';
if ($id === '' || $sigs === '' || !ctype_digit($ts) || abs(time() - (int)$ts) > 300) return false;
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
if ($key === false) return false;
$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true));
foreach (explode(' ', $sigs) as $s) {
[$v, $sig] = array_pad(explode(',', $s, 2), 2, '');
if ($v === 'v1' && hash_equals($expected, $sig)) return true;
}
return false;
}
// For the docs' test: body on stdin, the rest from the environment.
if (PHP_SAPI === 'cli' && realpath($argv[0]) === __FILE__) {
$ok = verify_webhook(getenv('SECRET'), ['webhook-id' => getenv('WEBHOOK_ID'), 'webhook-timestamp' => getenv('WEBHOOK_TIMESTAMP'),
'webhook-signature' => getenv('WEBHOOK_SIGNATURE')], stream_get_contents(STDIN));
echo $ok ? "valid\n" : "invalid\n";
}# Verify a Ranknera webhook (Standard Webhooks) in Ruby, standard library only.
# secret: your signing secret (whsec_…); headers: the request's headers (lower-case names); body: the raw body.
require "base64"
require "openssl"
def verify_webhook(secret, headers, body)
id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
return false if id.to_s.empty? || sigs.to_s.empty? || ts.to_s !~ /\A\d+\z/ || (Time.now.to_i - ts.to_i).abs > 300
key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{ts}.#{body}"))
sigs.split(" ").any? do |s|
v, sig = s.split(",", 2)
v == "v1" && sig && sig.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(sig, expected)
end
rescue ArgumentError
false
end
# For the docs' test: body on stdin, the rest from the environment.
if __FILE__ == $PROGRAM_NAME
ok = verify_webhook(ENV["SECRET"], { "webhook-id" => ENV["WEBHOOK_ID"], "webhook-timestamp" => ENV["WEBHOOK_TIMESTAMP"],
"webhook-signature" => ENV["WEBHOOK_SIGNATURE"] }, $stdin.read)
puts ok ? "valid" : "invalid"
endAnswer 2xx within 10 seconds. A 408, 429, 5xx, timeout or network error is retried after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h (8 attempts, about 28 hours); any other 4xx is final, and redirects are not followed. A webhook whose deliveries all fail for 5 days is turned off and you get an email; turning it on again sends new events only. The delivery log keeps 30 days, and a failed delivery can be sent again for 7 days.
| Event | When |
|---|---|
run.completed | A run ends, whatever the outcome (data.detail.outcome: drafted, needs_review, published, failed). |
article.drafted | A draft is created in WordPress. |
article.needs_review | An article waits for your review. |
article.published | An article goes live, now or at its scheduled time. |
article.scheduled | An article is scheduled in WordPress. |
article.publish_failed | WordPress refused a publish; data.detail.reason says why. |
article.updated | An article waiting for review was edited. |
article.deleted | An article was deleted from history or discarded. |
article.refreshed | A ranking check rewrote a slipping article. |
keyword.created | A keyword was added, from any source. |
keyword.updated | A keyword's text or status changed. |
keyword.deleted | A keyword was deleted. |
site.created | A site was added. |
site.updated | A site's details changed. |
site.deleted | A site was removed from Ranknera. |
site.connection_failed | Ranknera couldn't reach a site's WordPress. |
plan.completed | A topic plan finished. |
competitor_gaps.completed | A competitor gap search finished. |
performance.checked | A ranking check recorded results for an article. |
autopilot.paused | Autopilot stopped itself (budget, plan, WordPress unreachable). |
Events feed
GET /api/v1/events?since=<cursor>&types=article.published,run.completed returns your events of the last 90 days, oldest first, in the same shape as webhooks. Keep next_cursor and pass it as since next time: use it to backfill, to catch up after downtime, or instead of webhooks. Each event needs the read permission of what it describes.
Versions and limits
This is version 1. New fields, endpoints, events and tools are added without a new version; a breaking change gets /api/v2, and v1 keeps working for at least 12 months after, announced here, by email to keys that used it, and with Deprecation and Sunset headers. Each credential may make 600 REST requests a minute (60 for writing, publishing, bulk and research) and 120 MCP calls (20 for tools that confirm first). Bodies are capped at 1 MB. After 50 failed sign-ins in 5 minutes an address waits 15 minutes.
Building something? Email [email protected]. More about the product in Help.