Recipes
The jobs people actually use the API for, written out end to end.
Each recipe is complete and safe to run: the ones that spend money check first, use idempotency keys, and handle the refusals they can realistically hit.
Export current positions to CSV
The most common request. Note the handling of position: null — it is not zero.
#!/usr/bin/env bash
set -euo pipefail
: "${PICORANK_KEY:?set PICORANK_KEY}"
DOMAIN_ID="${1:?usage: export.sh <domain_id>}"
echo "keyword,engine,device,geography,position,url"
cursor=""
while :; do
url="https://api.picorank.com/v1/results/rankings?domain_id=$DOMAIN_ID&limit=200"
[ -n "$cursor" ] && url="$url&cursor=$cursor"
page=$(curl -sS "$url" -H "Authorization: Bearer $PICORANK_KEY")
# A notice means the answer is "no data, and here is why" — not a failure.
notice=$(printf '%s' "$page" | jq -r '.notice.code // empty')
if [ -n "$notice" ]; then
echo "# $notice: $(printf '%s' "$page" | jq -r '.notice.detail')" >&2
fi
printf '%s' "$page" | jq -r '.data[] |
[.keyword, .engine, .device, .geography,
(if .position == null then "not in top 50" else (.position|tostring) end),
(.ranking_url // "")] | @csv'
[ "$(printf '%s' "$page" | jq -r '.has_more')" = "true" ] || break
cursor=$(printf '%s' "$page" | jq -r '.next_cursor')
done Add keywords in bulk, safely
Check the slot cost first. Discovering you are out of capacity partway through a 400-keyword import is worse than being told before you start, and the dry run is free.
import { readFileSync } from "node:fs";
const KEY = process.env.PICORANK_KEY;
const BASE = "https://api.picorank.com/v1";
const listId = process.argv[2];
const keywords = readFileSync(process.argv[3], "utf8").split("\n").map(s => s.trim()).filter(Boolean);
async function call(path, init = {}) {
const res = await fetch(BASE + path, {
...init,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...init.headers },
});
const body = await res.json();
if (!res.ok) {
// Every refusal carries the numbers that produced it.
const e = new Error(`${body.code}: ${body.detail}`);
e.code = body.code;
e.meta = body.meta;
throw e;
}
return body;
}
// 1. Would this fit?
const check = await call("/keywords", {
method: "POST",
body: JSON.stringify({ list_id: listId, keywords, dry_run: true }),
}).catch((e) => {
if (e.code === "slot_limit_exceeded") {
console.error(`Not enough slots: ${e.meta.used}/${e.meta.limit} used, this needs ${e.meta.would_add}.`);
process.exit(1);
}
throw e;
});
console.log(`${check.data.requested} keywords would use ${check.data.slots.would_add} slots.`);
// 2. Commit, with an idempotency key so a network retry cannot double-add.
const result = await call("/keywords", {
method: "POST",
headers: { "Idempotency-Key": `import-${listId}-${keywords.length}` },
body: JSON.stringify({ list_id: listId, keywords }),
});
console.log(`Added ${result.data.added}; ${result.data.unchanged} already tracked.`); Start a scan and wait for it
reused: true means you joined a run already in flight — deliberate, so two callers cannot buy the same
search results twice.
import time, os, requests
KEY = os.environ["PICORANK_KEY"]
BASE = "https://api.picorank.com/v1"
H = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
r = requests.post(f"{BASE}/jobs", headers={**H, "Idempotency-Key": "nightly-2026-08-01"},
json={"kind": "rank_run"})
if r.status_code == 402:
p = r.json()
raise SystemExit(f"{p['code']}: {p['detail']}\n{p['remedy']}")
r.raise_for_status()
job = r.json()["data"]
print("joined a run already in progress" if job.get("reused") else "started a new run")
while job["status"] in ("queued", "running"):
time.sleep(15)
job = requests.get(f"{BASE}/jobs/rank_run/{job['id']}", headers=H).json()["data"]
p = job.get("progress") or {}
print(f" {job['status']} {p.get('done', 0)}/{p.get('total', 0)}")
if job["status"] == "error":
raise SystemExit(f"Run failed: {job['error_detail']}")
print("done") Check your position before expensive work
curl -s https://api.picorank.com/v1/account/usage \
-H "Authorization: Bearer $PICORANK_KEY" \
| jq -r '.data[] | select(.limit != null) |
"\(.label): \(.used)/\(.limit) used, resets \(.resets_at[0:10])"' One request instead of five
curl -s https://api.picorank.com/graphql \
-H "Authorization: Bearer $PICORANK_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ domains { host slots { used limit } scans { name geographies } competitors { host } } }"}' Give a client access to only their site
Create a key in the app scoped to that one domain, with read permission. It cannot enumerate your other domains, cannot read account-level data, and cannot change anything — enforced by the database, not only by the API. Add an expiry so an engagement that ends also ends the access.
Handling errors well
Branch on code, never on the message. Messages are for humans and may be reworded; codes are a contract.
async function pico(path, init) {
const res = await fetch(`https://api.picorank.com/v1${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.PICORANK_KEY}`, ...init?.headers },
});
if (res.ok) return res.json();
const problem = await res.json();
switch (problem.code) {
case "rate_limited":
case "cooldown_active":
// Both tell you when to come back.
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 30) * 1000));
return pico(path, init);
case "quota_exhausted":
case "cost_cap_reached":
case "entitlement_required":
// Not retryable. Surface the remedy to a human — it names the fix.
throw new Error(`${problem.detail} ${problem.remedy}`);
case "not_yet_scanned":
case "no_data_for_period":
return { data: [] }; // A fact about the data, not a failure.
default:
throw new Error(`${problem.code} (request ${problem.request_id}): ${problem.detail}`);
}
}