API documentation

Three endpoints, one auth header, billing per resolved citation. Everything you need to integrate — every example here was run against production.

Authentication

Bearer token in the header. An unknown key, a disabled key and a missing header all return the same 401 — we do not hint which one failed.

Authorization: Bearer cyt_live_…

Two kinds of keys

A cyt_test_ key uses the same corpus and returns the same answers but is never billed — 100 citations a month for integration and error handling. A cyt_live_ key serves production traffic and counts towards your plan. The key is shown exactly once when issued; we store only its hash.

What you pay for

The billing unit is a RESOLVED CITATION — one sentence matched to a source with a page number. A miss (found: false) is free, and so is BACKGROUND material in /chapter-sources (relevance below 7) — you pay only for sources squarely on topic. A NEW TOPIC is metered separately: building the corpus for a subject we do not have yet takes minutes and costs two orders of magnitude more, which is why you trigger it deliberately with harvestOnMiss. Above your plan, calls keep working at overage rates (added to the next invoice) — but only up to an EXTRA 100% of the plan; beyond that you get 429 until the 1st of the month or until you upgrade. Your bill can never surprise you by more than twice the plan.

PlanPrice / moCitationsNew topicsOverage
Testfree1003
Basic$194005$0.04 / $1.00
Start$491,20015$0.04 / $1.00
Pro$993,00040$0.04 / $1.00

Endpoints

GET /usage

Plan state and usage for the current month. Costs nothing and consumes nothing — the cheapest way to check that a key works.

curl -s -H "Authorization: Bearer $CYTADO_KEY" \
  https://cytado.com/api/ext/usage
import os, requests

r = requests.get(
    "https://cytado.com/api/ext/usage",
    headers={"Authorization": f"Bearer {os.environ['CYTADO_KEY']}"},
    timeout=60,
)
print(r.json())
const r = await fetch("https://cytado.com/api/ext/usage", {
  headers: { Authorization: `Bearer ${process.env.CYTADO_KEY}` },
});
console.log(await r.json());

POST /find-source

A sentence in, a source with a page number out. The only billable call — and only when it finds something. Up to 60 items per request; the excess is dropped EXPLICITLY. The hint field is the topic of the work: without it we ask “does this source support this sentence”, with it “could someone writing THIS paper cite it”. Every hit carries wsparcie — does the RETURNED snippet itself back the claim: wprost (cite as-is), posrednie (read and qualify), brak (the snippet does not state it — look elsewhere), null (not assessed).

curl -s -X POST https://cytado.com/api/ext/find-source \
  -H "Authorization: Bearer $CYTADO_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "id": "a1",
      "claim": "Procrastination is accompanied by guilt and psychological discomfort.",
      "hint": "procrastination among university students"
    }
  ],
  "langs": [
    "en"
  ],
  "harvestOnMiss": false
}'
import os, requests

r = requests.post(
    "https://cytado.com/api/ext/find-source",
    headers={"Authorization": f"Bearer {os.environ['CYTADO_KEY']}"},
    json={
  "items": [
    {
      "id": "a1",
      "claim": "Procrastination is accompanied by guilt and psychological discomfort.",
      "hint": "procrastination among university students"
    }
  ],
  "langs": [
    "en"
  ],
  "harvestOnMiss": False
},
    timeout=600,
)
r.raise_for_status()
print(r.json())
const r = await fetch("https://cytado.com/api/ext/find-source", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CYTADO_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "items": [
    {
      "id": "a1",
      "claim": "Procrastination is accompanied by guilt and psychological discomfort.",
      "hint": "procrastination among university students"
    }
  ],
  "langs": [
    "en"
  ],
  "harvestOnMiss": false
}),
});
if (!r.ok) throw new Error(`cytado ${r.status}: ${await r.text()}`);
console.log(await r.json());

POST /chapter-sources

A chapter topic in, a set of citable sources out, ranked by topic coverage. You are billed ONLY for sources squarely on topic (relevance >= 7) — and by default only those are returned; background material (relevance 5-6, free) is available on explicit minRelevance: 5. Every source carries wsparcie: does the returned excerpt itself support the chapter (wprost / posrednie / brak, null = not assessed). The chapter field is optional: without it sources are picked for the topic as a whole; with it, scores are anchored to that chapter. limit is capped at 15; limit: 0 (or negative) is a free no-op returning nothing — natural for `limit: target - have` loops. The exclude field lists sources already used — coverage is then computed over what remains, so consecutive chapters do not keep receiving the same material.

curl -s -X POST https://cytado.com/api/ext/chapter-sources \
  -H "Authorization: Bearer $CYTADO_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "topic": "screen time in preschool children",
  "chapter": "Introduction",
  "exclude": []
}'
import os, requests

r = requests.post(
    "https://cytado.com/api/ext/chapter-sources",
    headers={"Authorization": f"Bearer {os.environ['CYTADO_KEY']}"},
    json={
  "topic": "screen time in preschool children",
  "chapter": "Introduction",
  "exclude": []
},
    timeout=600,
)
r.raise_for_status()
print(r.json())
const r = await fetch("https://cytado.com/api/ext/chapter-sources", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CYTADO_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "topic": "screen time in preschool children",
  "chapter": "Introduction",
  "exclude": []
}),
});
if (!r.ok) throw new Error(`cytado ${r.status}: ${await r.text()}`);
console.log(await r.json());

The shape of a /find-source response

{
  "results": [
    {
      "id": "a1",                      // twoje id, oddane bez zmian
      "claim": "…",                    // echo tezy, o która pytales
      "found": true,
      "source": {                      // UWAGA: pola zrodla sa TUTAJ, nie plasko
        "title": "…",
        "authors": "…",
        "year": 2021,
        "url": "https://doi.org/10.12740/pp/95085",
        "doi": "10.12740/pp/95085",
        "page_from": 1088,
        "page_to": 1090,
        "snippet": "…",
        "relevance": 9
      }
    },
    { "id": "a2", "claim": "…", "found": false }
  ],
  "billing": { "citations_charged": 1, "topics_charged": 0 },
  "harvested": 0
}

No source simply means no source field. Sixty items per request is fine, but budget about 1.6 s per claim — a full batch takes ~95 s. Set your client timeout accordingly; the gateway allows 800 s.

Response fields worth understanding

found
false means the corpus has nothing to support this claim. We do not return the nearest match instead, and we do not charge.
page_from / page_to
The PRINTED page number — the one a supervisor checking your footnote will land on, not the sheet number inside the PDF.
page_from_label
Filled only for unusual numbering (e.g. “E136”). An empty label with a filled page_from means “the printed number is simply 152”, not “no pagination”.
relevance
9–10: directly about this claim and this group. 7–8: the same phenomenon in a related setting — different profession, age or country; citable, but mark the context.
doi
Raw DOI when we have one, e.g. 10.36921/abc — without the https://doi.org/ prefix. Dedupe on this: the same work reaches you through several routes and only the DOI is stable.
exclude (request)
Sources you already cite — URLs or DOIs, in any form (10.x, doi:10.x or https://doi.org/10.x all match). Filtering happens BEFORE the rerank, so excluding what you already have makes the call cheaper, not just tidier. Measured on a real thesis: without it, 40-50% of answers were works already in the bibliography.
claim
Echo of the claim you sent, alongside its id — so you do not have to keep your own id→sentence map while placing footnotes.
url
A link to the original — ALWAYS a full, clickable address (a bare DOI is returned as https://doi.org/…). EVERY returned source has one; for more than half the corpus it opens the full text, because those are open-access publications.
snippet
A short excerpt under fair use. We never return the full text of sources we may not republish — we return the link under which the reader can read them at the publisher.
billing
Exactly what this request was charged for: citations_charged, already_paid (documents paid for earlier on this topic) and free_background — how many returned positions were free background material (relevance below 7). A zero in free_background means everything returned was squarely on topic.

Limits and errors

401 unauthorized
Unknown, disabled or missing key.
400 invalid-json
The request body is not valid JSON.
400 missing-items
The required `items` array is missing.
400 unknown-field
An unknown field in the request — with a suggestion when it looks like a typo (`min_relevance` → “did you mean minRelevance?”). We do not silently ignore fields we do not know: a typo in a parameter name must not silently cost you money.
422 invalid_request
Syntactically valid but a bad VALUE in a known field — the response names the field. E.g. a topic shorter than 8 or longer than 4000 characters, langs not an array of known codes, exclude not an array of strings, minRelevance not a number.
429 rate_limited
Limit exceeded. Retry-After says how many seconds to wait. The per-minute guard means “slow down”, the daily limit means “done for today”, the overage cap (plan + 100%) means “until the 1st or upgrade” — the message distinguishes them.
402
Plan exhausted on an operation that requires paid access.

Long operations: harvest and polling

Reading an existing corpus takes seconds. Building one for a topic we do not have yet takes 5-30 minutes — we download and read the actual PDFs. You cannot wait for that inside a single HTTP request: there is an nginx timeout on our side and a client timeout on yours. That is what the async mode of /corpus is for: you enqueue, then you poll.

# 1. Enqueue. Returns immediately.
curl -s https://cytado.com/api/ext/corpus \
  -H "Authorization: Bearer $CYTADO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"topic":"persuasion in advertising","langs":["en"],"minCoverage":6,"async":true}'

# {"jobId":82,"coverage":7,"coverageWprost":1,"corpusReady":false,"async":true}
# jobId = null  ->  corpus already sufficient, nothing was started, nothing billed.

# 2. Poll every 20-30 s. Free: polling is never billed.
curl -s https://cytado.com/api/ext/corpus/status \
  -H "Authorization: Bearer $CYTADO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jobId":82,"topic":"persuasion in advertising","langs":["en"]}'

# {"jobId":82,"status":"running","ready":false,"coverage":9,"coverageWprost":4}
# status: queued | running | done | failed | unknown
# stop when "ready" is true, then call /chapter-sources

Poll every 20-30 seconds and set your own deadline — a failed status is a legitimate answer, not a broken connection. If the job fails, the corpus stays as it was and you can work with what is already there.

The same in Python — ready to copy:

import os, time, requests

BASE = "https://cytado.com/api/ext"
HEAD = {"Authorization": f"Bearer {os.environ['CYTADO_KEY']}"}

def ensure_corpus(topic, langs=("pl", "en"), min_coverage=6, limit_s=1800):
    """Ensure the corpus covers a topic. Returns final coverage.

    Blocks for up to limit_s. A harvest is billed once as a New topic;
    polling is free. jobId None means the corpus was already sufficient
    and nothing was started or charged.
    """
    r = requests.post(f"{BASE}/corpus", headers=HEAD, timeout=120, json={
        "topic": topic, "langs": list(langs),
        "minCoverage": min_coverage, "async": True,
    })
    r.raise_for_status()
    state = r.json()
    if not state.get("jobId"):
        return state.get("coverage", 0)          # already covered, no charge

    deadline = time.time() + limit_s
    while time.time() < deadline:
        time.sleep(25)                          # 20-30 s is the sane cadence
        try:
            s = requests.post(f"{BASE}/corpus/status", headers=HEAD, timeout=60,
                              json={"jobId": state["jobId"], "topic": topic,
                                    "langs": list(langs)})
            s.raise_for_status()
        except requests.RequestException:
            continue                            # transient: keep polling
        state2 = s.json()
        if state2.get("ready"):                 # done OR failed — both final
            return state2.get("coverage", 0)
    return state.get("coverage", 0)              # our deadline, not an error

def chapter_sources(topic, chapter, used=(), limit=12):
    """Sources for one chapter. Pass previously used URLs/DOIs in 'used'
    so the next chapter gets NEW material instead of the same top hits."""
    r = requests.post(f"{BASE}/chapter-sources", headers=HEAD, timeout=600, json={
        "topic": topic, "chapter": chapter, "langs": ["pl", "en"],
        "limit": limit, "exclude": list(used),
    })
    r.raise_for_status()
    return r.json()

ensure_corpus("persuasion in advertising")
data = chapter_sources("persuasion in advertising", "Chapter 1")
print(data["coverage"], data["coverageWprost"], data["billing"])

Windows and PowerShell: send the body as UTF-8

In PowerShell 5.1, Invoke-RestMethod -Body <string> encodes the body using the system code page, not UTF-8. Non-ASCII characters — Polish diacritics, German umlauts, Cyrillic — are destroyed before the request leaves the machine. The topic reaches us mangled, the search runs on the wrong text, and nothing signals it. Pass BYTES instead of a string:

$body  = @{ topic = "calibration of pressure transducers" } | ConvertTo-Json -Compress
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)   # <-- required
Invoke-RestMethod -Uri "$BASE/corpus" -Method Post -Headers $h -Body $bytes

PowerShell 7 and curl.exe get this right on their own. The problem is specific to the PowerShell 5.1 that ships with Windows.

Successive chapters: the exclude parameter

Without exclude, chapter two receives the same top sources as chapter one — because they objectively are the best. Pass the URLs or DOIs you have already used, and coverage is computed over what is still available for the NEXT chapter rather than over the whole topic.

curl -s https://cytado.com/api/ext/chapter-sources \
  -H "Authorization: Bearer $CYTADO_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "topic": "persuasion in advertising",
        "chapter": "Chapter 2: emotional appeals",
        "langs": ["en"],
        "limit": 12,
        "exclude": ["https://doi.org/10.1234/abc", "10.5678/def"]
      }'

# {
#   "sources": [ ... ],
#   "coverage": 9,            # documents still available (excluded ones removed)
#   "coverageWprost": 5,      # of which are squarely ON the topic, not adjacent
#   "billing": { "citations_charged": 9, "already_paid": 3 }
# }

coverageWprost matters more than coverage. A gap like “coverage 6, coverageWprost 0” means the corpus holds material from the FIELD but nothing about the specific thing you asked for — that is the moment to run a harvest instead of writing from what is there.

Check the cost before you commit

The same /corpus call may read an existing corpus (pennies) or build one from scratch (a new topic, several minutes). What happens depends on the state of the corpus, which you cannot see from outside. Add dryRun and we run exactly the same gate and tell you what WOULD happen — without creating a job and without billing anything.

curl -s https://cytado.com/api/ext/corpus \
  -H "Authorization: Bearer $CYTADO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"topic":"persuasion in advertising","langs":["en"],
       "minCoverage":6,"dryRun":true}'

# {
#   "dryRun": true,
#   "coverage": 9,
#   "coverageWprost": 5,
#   "corpusReady": true,
#   "wouldHarvest": false,     # a real call would NOT start a harvest
#   "wouldBill": null          # ...and would bill nothing
# }
#
# When the corpus is thin:
#   "wouldHarvest": true,
#   "wouldBill": { "kind": "topic", "count": 1 }

The preview is free and unlimited. Use it as a decision step: dryRun first, then the same call without the field if the cost suits you.

Test keys and new topics

A cyt_test_ key can build 3 NEW TOPICS per month — enough to exercise the async mode, polling and webhooks, which is the hard part of any integration. Once that allowance is used, reading the corpus and verification keep working unchanged; only building a corpus for a new topic is blocked, because it is the most expensive operation in the API. Use a cyt_live_ key for production traffic.

No Postman needed: the panel console

All three endpoints — /corpus, /chapter-sources and /find-source — can be called straight from the panel (Account → API → Console), with no key to paste and nothing to configure. Every field is described by what it does, not by its type. The same call is shown as curl underneath, ready to paste into your code.

We state the cost BEFORE you send, but differently per endpoint, because it arises differently in each. /corpus has a two-step button: the first checks the cost and cannot bill anything, the second — with the price on it — only appears under the answer. /chapter-sources and /find-source state a ceiling up front, because the ceiling is the number you typed yourself: the source limit or the number of claims. Watch one field: harvestOnMiss in /find-source can build up to 24 new topics in a single click, which is why it is off by default.

maxNewTopics — cap your own bill

In /find-source, harvestOnMiss pulls sources from the web for claims that cannot be resolved from the existing corpus. Every such pull is a NEW TOPIC — an item roughly 25x more expensive than a citation. Without this field we build at most 6 topics per request; the server's hard ceiling is 24. Pass maxNewTopics to set your own limit — including a higher one, up to 24. Out-of-range values are clamped to 0-24 rather than rejected.

{ "items": [...], "langs": ["en"],
  "harvestOnMiss": true,
  "maxNewTopics": 3 }        # at most 3 new topics in this request

What the console is NOT

The console runs the same code as an external request — same limits, same test-key ceiling, same billing, same log entry. A call made from the panel costs exactly what a call from your server costs; it is not a free mode. It does skip the HTTP layer, so it will not surface a bad header or a body-serialisation bug — when debugging transport, use the curl shown under the form, since only that goes through the full stack.

MCP server — for AI agents

The whole API is also available as an MCP (Model Context Protocol) server — an agent connects once and gets tools whose descriptions tell it when to call them and what is billed. Same key as REST, same billing, same usage in the panel. Transport: Streamable HTTP, stateless.

# Claude Code
claude mcp add cytado --transport http https://cytado.com/api/mcp \
  --header "Authorization: Bearer $CYTADO_KEY"

# Claude API (MCP connector)
"mcp_servers": [{ "type": "url", "url": "https://cytado.com/api/mcp",
                  "name": "cytado", "authorization_token": "$CYTADO_KEY" }]

# stdio-only clients
npx -y mcp-remote https://cytado.com/api/mcp \
  --header "Authorization: Bearer $CYTADO_KEY"

MCP tools never harvest synchronously — ground_citation answers from the corpus in seconds, and pulling new topics always runs in the background via a jobId. Billing semantics are stated right in the tool descriptions, so the agent knows what costs money before it calls.

Connecting in the claude.ai chat window

You can add cytado as a custom connector directly in claude.ai — the tools then appear in a normal conversation, no terminal and no code:

  1. In claude.ai open Settings → Connectors (on Team/Enterprise plans the organization owner does this in organization settings) and click “Add custom connector”.
  2. As the server URL enter: https://cytado.com/api/mcp
  3. In the Request headers section add an Authorization header with the value “Bearer ” + your API key from the panel (Account → API). Claude stores the value securely and sends it with every request.
  4. In a conversation click “+” → Connectors and enable cytado. From then on you can ask: “write a paragraph about X and ground the claims in sources via cytado” — the agent calls the tools itself.

Note: the Request headers field in claude.ai is a beta feature on gradual rollout — if you do not see it, your account currently supports OAuth connectors only. OAuth sign-in for cytado is in the works; until then use Claude Code or the Claude API MCP connector (examples above). In ChatGPT you add the connector analogously in developer mode (Settings → Connectors), with the same URL and header.

HTTP notifications (webhooks)

Instead of polling us about your plan, give us a URL and we will send a signed POST: at 80% and 100% of the plan, when a billing period closes, and after an invoice is issued. You set the URL and the secret in the panel.

POST https://your-app.example/cytado-hook
X-Cytado-Event: job.completed
X-Cytado-Timestamp: 1785858025
X-Cytado-Signature: 9f2c...          # HMAC-SHA256 over "<timestamp>.<raw body>"

{
  "event": "job.completed",
  "at": 1785858025,
  "data": {
    "jobId": 83,
    "topic": "persuasion in advertising",
    "coverage": 15,
    "coverageWprost": 10
  }
}

# Verify in Node — sign the RAW body, never re-serialised JSON:
import { createHmac, timingSafeEqual } from "node:crypto";

const expected = createHmac("sha256", SECRET)
  .update(`${req.headers["x-cytado-timestamp"]}.${rawBody}`)
  .digest("hex");
const provided = req.headers["x-cytado-signature"];
const ok =
  provided.length === expected.length &&
  timingSafeEqual(Buffer.from(expected), Buffer.from(provided));

Sign the RAW body, not re-serialised JSON — reordered keys change the signature. Reject requests older than a few minutes. We make one delivery attempt per event: the webhook is a convenience, not the source of truth — the panel and the invoice remain that.

API changes

We do not version URLs. Instead we keep one promise: fields never disappear and never change meaning, while new ones may appear at any time. Parse responses leniently — ignore an unknown field rather than treating it as an error.

What is safe to retry

/corpus/status
Freely — they do not touch the corpus and are never billed.
/chapter-sources
Safely. Documents you already paid for on this topic are not charged again — you will see them under billing.already_paid.
/corpus (async: true)
Safe within a 30-minute window: a repeated request for the same topic returns THE SAME jobId and does not bill a second topic.
/find-source
Retrying after a timeout may bill resolved citations twice — if you retry automatically, keep track of what already came back.

Start with a test key

100 citations a month, never billed, no card. You issue the key yourself in the panel, right after signing up.

Create an account