# CiteTrue API

Verify citations programmatically. One endpoint, streamed over SSE.

- **Base URL**: `https://api.citetrue.com`
- **Auth**: Bearer API key (`sk_…`)
- **Transport**: HTTP + Server-Sent Events (`text/event-stream`)
- **Rate limit**: 10 req/s burst, 60 req/min sustained, per key
- **Docs HTML version**: <https://citetrue.com/docs/api>

This file is optimized for LLMs / automation. Human-friendly HTML with syntax highlighting is at the URL above.

> **Using Claude Desktop, Cursor, or another MCP-capable LLM client?** Skip the HTTP plumbing — install [`@citetrue/mcp-server`](https://citetrue.com/docs/mcp) and call the `verify` tool directly from your chat. Same credits, same accuracy, no SSE parsing. ([click here for MCP docs](https://citetrue.com/docs/mcp))

---

## Quickstart

1. Go to Dashboard → API Keys → click "New API Key". Copy the key starting with `sk_`. It is shown once.
2. Include the key in `Authorization: Bearer sk_…` on every request.
3. POST to `/verify/v2`. The response is a `text/event-stream`; consume SSE events until you see `task_completed`.

Minimal deep-verify call:

```bash
curl -N -X POST https://api.citetrue.com/verify/v2 \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"depth":5,"text":"[1] Vaswani, A., Shazeer, N., Parmar (2017). Attention is all you need."}'
```

`-N` disables curl buffering so SSE events stream line by line. `depth` selects the verification level: `1` = fast (1 credit/ref) or `5` = deep (5 credits/ref). `20` exists as a closed-beta tier but is not enabled for the public API. Omit `depth` for the default `1`.

---

## Client examples

SSE is a long-lived HTTP response where each event ends with a blank line (`\n\n`). Skip lines that don't start with `data: ` and JSON-parse the rest.

**Browser note**: the built-in `EventSource` can't send an `Authorization` header — use `fetch` + `ReadableStream`.

### Node.js (18+, native fetch)

```js
const API = 'https://api.citetrue.com'
const KEY = process.env.API_KEY

async function verify(text) {
  const resp = await fetch(`${API}/verify/v2`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ depth: 5, text }),
  })
  if (!resp.ok) throw new Error(`${resp.status}: ${await resp.text()}`)

  const reader = resp.body.getReader()
  const decoder = new TextDecoder()
  const refs = []
  let buf = ''

  while (true) {
    const { value, done } = await reader.read()
    if (done) break
    buf += decoder.decode(value, { stream: true })
    let idx
    while ((idx = buf.indexOf('\n\n')) !== -1) {
      const chunk = buf.slice(0, idx); buf = buf.slice(idx + 2)
      for (const line of chunk.split('\n')) {
        if (!line.startsWith('data: ')) continue
        const evt = JSON.parse(line.slice(6))
        if (evt.event === 'ref_completed') {
          const ref = evt.data.ref
          refs.push({
            text: ref.text,
            assessment: ref.assessment,
            value: ref.value,
            notices: ref.notices,
            confidence: ref.confidence,
          })
        }
        if (evt.event === 'task_completed') return { refs, balance: evt.data.balance }
        if (evt.event === 'error') throw new Error(evt.error)
      }
    }
  }
}
```

### Python (requests, streaming)

Requires `pip install requests`.

```python
import json, os, requests

API = 'https://api.citetrue.com'
KEY = os.environ['API_KEY']

def verify(text):
    resp = requests.post(
        f'{API}/verify/v2',
        headers={'Authorization': f'Bearer {KEY}', 'Content-Type': 'application/json'},
        json={'depth': 5, 'text': text},
        stream=True,
        timeout=(10, 300),
    )
    resp.raise_for_status()
    refs = []
    for raw in resp.iter_lines(decode_unicode=True):
        if not raw or not raw.startswith('data: '):
            continue
        evt = json.loads(raw[6:])
        kind = evt.get('event')
        if kind == 'ref_completed':
            ref = evt['data']['ref']
            refs.append({
                'text': ref['text'],
                'assessment': ref.get('assessment'),
                'value': ref.get('value'),
                'notices': ref.get('notices'),
                'confidence': ref.get('confidence'),
            })
        elif kind == 'task_completed':
            return {'refs': refs, 'balance': evt['data']['balance']}
        elif kind == 'error':
            raise RuntimeError(evt.get('error'))
```

**Cancel a task**: there's no `/cancel` endpoint — close the response socket. The server detects disconnect within a few seconds and aborts the task. You're charged only for references that finished, at their actual cost; references that didn't complete are not charged.

---

## Authentication

API keys are long-lived bearer tokens in the shape `sk_<40-char-token>`. Send them on the `Authorization` header:

```
Authorization: Bearer sk_AbCd123…
```

- Revoke a key from the Dashboard. Revocation is effective within a few seconds.
- A key can perform anything the owning user can. Treat it like a password — rotate and scope per integration.

---

## Rate limits

Each API key gets a token-bucket limiter:

- **Burst**: 10 requests.
- **Sustained**: 60 requests / minute (bucket refills at 1 token/s).
- When exceeded, the request fails with `429` and a `Retry-After` header (seconds).

The limiter runs per key; different keys on the same account have independent buckets. Credits apply on top of this.

---

## Common request shape

Every endpoint accepts the same auth + content-type headers; only the body and URL differ.

| Header | Required | Value |
|---|---|---|
| `Authorization` | yes | `Bearer sk_…` |
| `Content-Type` | yes | `application/json` |

Responses are `200 OK` with `Content-Type: text/event-stream`. Every line begins `data: ` followed by a JSON envelope:

```
data: {"event":"<name>","data":{...},"error":""}
```

Consume until `task_completed` (success) or `error` (terminal failure). Server aborts the task automatically if the client disconnects.

---

## POST /verify/v2

Splits a text blob into references and verifies each. The same endpoint covers three modes — supply *exactly one* of `text`, `refHash`, or `taskHash`.

### Request body

| Field | Type | Notes |
|---|---|---|
| `text` | string | New verify task. Max 10MB. Numbered / bulleted / blank-line-separated / BibTeX, or in-text prose with `(Author, Year)` citations (depth ≥ 5 only). |
| `refHash` | string | Upgrade a single prior reference to a deeper run. Requires `depth ≥ 5` and `parentTaskHash`. |
| `parentTaskHash` | string | The task this reference originally belonged to. Required when using `refHash`. |
| `taskHash` | string | Resume an existing task — replays cached state or attaches to live stream. No re-billing for already-charged refs. |
| `depth` | int / string | `1` (default) / `5` / `20`. Selects verification level + cost. |
| `force` | bool | Bypass the task-level dedup cache (forces a fresh run, charges full cost). |
| `locale` | string | Optional 2-letter language code (`zh`, `ja`, `de`, …). When set, AI translates the per-ref `note` into that language and surfaces it under `noteTranslated[locale]`. Default `""` / `"en"` = no translation. |

### Depth values

- `1` — fast: handles structured reference lists and inline prose (auto-falls back to AI splitter on hard inputs). 1 credit per reference.
- `5` — deep: AI-driven verification; handles unusual formats and gives a stronger verdict. Each reference first gets a fast verification pass; if that already finds it **authentic**, it's billed just **1 credit** and deep verification is skipped — otherwise the deep pass runs and it's billed **5** (see [Deep verification billing](#deep-verification-billing)).
- `20` — _closed beta, not enabled for public API._ Same flow as `5` with a higher-tier model.

**Idempotency**: same `text` + same `depth` + same user returns the cached task on second POST (no re-billing) unless `force: true`.

### Examples

New task (default depth=1):

```json
{
  "text": "[1] Vaswani, A., Shazeer, N., Parmar (2017). Attention is all you need.\n[2] Piketty, T. (2016). Capital in the twenty-first century. Harvard University Press."
}
```

Deep (depth=5) from text:

```json
{
  "depth": 5,
  "text": "[1] Vaswani, A., Shazeer, N., Parmar (2017). Attention is all you need."
}
```

Deep upgrade of a single prior reference:

```json
{
  "depth": 5,
  "refHash": "a1b2c3d4e5f6...",
  "parentTaskHash": "f0e1d2c3b4a5..."
}
```

Resume an existing task:

```json
{ "taskHash": "f0e1d2c3b4a5..." }
```

### Event sequence

```
task_created → refs_created (N refs) →
  [ref_processing + ref_completed] × N (parallel) +
  task_progress × several →
task_completed
```

### Per-ref assessment

Each `ref_completed` event carries an `assessment` string from this closed set:

- `authentic` — paper exists and is the one cited. `value` contains the matched paper.
- `unsure` — found something but can't confirm; `notices` and `confidence` describe why.
- `inauthentic` — definitively not found / fabricated / unrelated.
- `invalid` — input wasn't a citation; pipeline refused to search before any datasource hit.
- `error` — verification failed on this single ref; sibling refs may still succeed. `data.errors[]` lists tokens (`datasource_unavailable`, `timeout`, `rate_limited`, `parse_failed`).
- `exceeded` — account ran out of credits before this ref could run.

Notices on a result (year mismatch, author mismatch, …) are orthogonal to `assessment` — even an `authentic` verdict can carry notices worth showing. `data.depth` reports the highest depth that has run on this ref (`1` or `5`; `20` is closed-beta, not exposed publicly); clients derive a "deep-verified" flag locally as `depth >= 5`.

---

## SSE events

Every event is one line: `data: {"event":"<name>","data":{...},"error":"","warn":""}`. The wrapper envelope is stable; payload shapes below describe the `data` object.

| event | when |
|---|---|
| `task_created` | Task row persisted. |
| `refs_created` | A batch of refs after the splitter produces them. May fire multiple times during streaming split. |
| `split_completed` | Splitter finished — no further `refs_created` incoming. Total ref count is now known. |
| `ref_processing` | A ref started processing. Signal only. |
| `ref_status` | Live narration during long-running ref (deep verify). One short localized phase label per phase change. |
| `ref_completed` | Ref finished (any terminal assessment). |
| `task_progress` | Periodic overall progress (0..1) and optional status message. |
| `task_completed` | Terminal success (incl. `exceeded` partial runs). Stream ends. |
| `warn` | Non-fatal signal (e.g. `no_citations` when input has no recognisable citations). |
| `error` | Terminal failure. Stream ends. |

### task_created

Emitted once, after the task row is persisted.

```json
{
  "event": "task_created",
  "data": { "taskHash": "f0e1d2c3b4a5..." }
}
```

`taskHash` is stable across resumes; pass it back to the same endpoint as `taskHash` to resume. Format is opaque — don't parse.

### refs_created

Emitted (one or more times) as the splitter produces refs. `refs.length` = number of `ref_completed` events to expect from this batch.

```json
{
  "event": "refs_created",
  "data": {
    "refs": [
      { "hash": "a1b2c3...", "text": "[1] Vaswani...", "assessment": "" },
      { "hash": "d4e5f6...", "text": "[2] Piketty...", "assessment": "" }
    ]
  }
}
```

- `hash` uniquely identifies the ref; use it with `refHash` upgrade mode of `/verify/v2`.
- `assessment` is `""` for refs still to run; cached/resumed refs may arrive pre-populated.
- Multiple `refs_created` batches may stream as the splitter progresses; the `split_completed` event signals "no more refs incoming".

### split_completed

Emitted once after the splitter has produced all refs. Useful when you want a stable total before rendering progress. No payload — just a marker.

```json
{ "event": "split_completed" }
```

### ref_status

Live narration during long-running refs (deep verification). One short, already-localized phase label per phase change — meant for "agent is working on it" UI cues. Safe to ignore for headless integrations.

```json
{
  "event": "ref_status",
  "data": {
    "refHash": "a1b2c3...",
    "message": "Querying academic databases"
  }
}
```

- `message` is already localized to the request's `locale` (or English if absent).
- Not guaranteed to fire — fast (`depth=1`) refs typically complete without any `ref_status`.

### ref_completed

Emitted once per ref. Outcome fields (`assessment`, `value`, `note`, `notices`, `confidence`) flatten at the top of the ref payload; non-Outcome metadata (errors, depth, credits) lives under `data`.

```json
{
  "event": "ref_completed",
  "data": {
    "ref": {
      "hash": "a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4",
      "text": "[2] Piketty, T. (2016). Capital in the twenty-first century. Harvard University Press.",
      "assessment": "unsure",
      "value": {
        "type": "pdf",
        "title": "Capital in the twenty-first century",
        "authors": ["T Piketty"],
        "year": 2014,
        "url": "https://www.hup.harvard.edu/.../9780674430006",
        "pdfUrl": "",
        "citedBy": 26000
      },
      "note": "Year doesn't match the matched source.",
      "notices": ["year"],
      "confidence": 0.92,
      "data": {
        "depth": 1,
        "cost": 1
      }
    }
  }
}
```

**Ref-level fields**
- `hash` — stable 40-char hex SHA-1 of the normalized citation text. Same text always hashes to the same `ref.hash`.
- `text` — the exact citation text after whitespace normalization.
- `assessment` — one of `authentic / unsure / inauthentic / invalid / error / exceeded` (terminal).
- `value` — the matched paper (single object). Absent for `inauthentic` / `invalid` / `error` / `exceeded`.
- `note` — free-form one-line natural-language explanation in English (e.g. _"Year doesn't match the matched source."_). Empty on clean matches. Intended for end-user display; for programmatic logic match on `notices[]` instead.
- `noteTranslated` — optional `{ locale: string }` object cached when the request includes a non-English `locale`. Display logic: `noteTranslated[locale] ?? note`.
- `notices[]` — soft-fail flags (see below).
- `confidence` — float in `[0, 1]`; only meaningful when `assessment === "unsure"`.

**data fields (non-Outcome metadata)**
- `depth` — highest depth that has run on this ref: `1` (fast academic only) or `5` (deep AI). `20` exists as a closed-beta tier, not enabled for public API. Clients derive a "deep-verified" flag locally as `depth >= 5`.
- `errors[]` — only when `assessment === "error"`; tokens like `datasource_unavailable`, `timeout`, `rate_limited`, `parse_failed`.
- `cost` — credits actually charged for this ref. At `depth=1` always 1. At `depth=5`: **1** if the initial fast pass already found the reference authentic (deep skipped), otherwise **5**. 0 for replayed/resumed refs. This is the authoritative per-ref charge.

**value fields (matched paper)**
- `type` — `standard` (search engine hit), `citation` (DB record), `pdf` (resolvable PDF), `html` (landing page), `book`.
- `title / authors[] / year / org` — canonical metadata.
- `url` — landing page; `pdfUrl` — direct PDF (may be empty).
- `citedBy` — citation count (best-effort).

**Notice values** — strings from this set:

| value | meaning |
|---|---|
| `likely` | Only one candidate and the match is uncertain. |
| `year` | Year in citation doesn't match matched source. |
| `authors` | Author list mismatch. |
| `title` | Title differs between the citation and the matched source. |
| `link` | Citation has no URL / DOI to cross-check. |
| `url_inaccessible` | Cited URL couldn't be fetched. |
| `url_inconsistent` | URL resolves but content doesn't match metadata. |
| `url_mismatch` | URL points to a different paper. |
| `url_unsure` | URL parse was ambiguous. |
| `url_incorrect` | URL has malformed syntax. |
| `doi_incorrect` | DOI is malformed or doesn't resolve. |

New values may appear as more mismatch classes are detected — treat unknown tokens as a generic warning.

### task_progress

Periodic progress update. Fires after each `ref_completed` and during long-running phases.

```json
{
  "event": "task_progress",
  "data": { "progress": 0.66, "statusMessage": "Querying academic databases" }
}
```

- `progress` — float in `[0, 1]`, monotonic non-decreasing.
- `statusMessage` — optional short phase label; may be empty.

### task_completed

Terminal event for a successful task. Stream closes immediately after. Note: if credits run out mid-task, this is still emitted (not `error`) — remaining refs get `assessment: "exceeded"`.

```json
{
  "event": "task_completed",
  "data": {
    "taskHash": "f0e1d2c3b4a5...",
    "cost": 3,
    "balance": 247
  }
}
```

- `taskHash` — opaque stable identifier, same as `task_created`.
- `cost` — total cost of all non-exceeded refs (`Σ ref.data.credits`). Not "credits deducted this run" — on resume it shows the original cost.
- `balance` — remaining credits in the account after this task. Use this for actual-deduction tracking.

### warn

Non-fatal signal. The task usually still ends with `task_completed`; the warn just hints at what the splitter saw. Common values:

- `no_citations` — input contained no recognisable citation patterns.
- `invalid_content` — input wasn't a valid reference list.

```json
{
  "event": "warn",
  "warn": "no_citations"
}
```

New tokens may be added; treat unknown values as a generic informational warning and continue reading the stream.

### error

Terminal failure. Stream closes immediately after. No `task_completed` follows.

```json
{
  "event": "error",
  "error": "text, refHash, or taskHash is required"
}
```

Common values:
- `text, refHash, or taskHash is required` — none of the three accepted modes was supplied to `/verify/v2`.
- `refHash upgrade requires depth=5 or 20` — depth=1 must be run from text, not from a refHash.
- `task_failed` — internal error. Retry is usually safe (new taskHash).

A per-ref failure sets `assessment: "error"` on that single ref — the task as a whole still ends with `task_completed`. Stream-level `error` indicates the whole task couldn't run.

---

## GET /credits

Returns the credit balance and active (non-expired) credit records for the account. Cheap, plain JSON; not rate-bucketed.

```bash
curl -X GET https://api.citetrue.com/credits \
  -H "Authorization: Bearer $API_KEY"
```

Response 200:

```json
{
  "userId": "…",
  "totalBalance": 123,
  "credits": [
    {
      "id": 42,
      "type": "plan",
      "total": 500,
      "balance": 123,
      "expiredAt": "2026-05-01T00:00:00Z"
    }
  ]
}
```

- `totalBalance` — sum of `balance` across active credit records.
- `credits[].type` — `plan` (subscription grant) or `bonus_*` (e.g. `bonus_feedback`, `bonus_new_user`, `bonus_referral`). New `bonus_*` variants may appear; treat unknowns as a generic bonus.
- `credits[].total` — amount originally granted.
- `credits[].balance` — remaining after deductions.
- `credits[].expiredAt` — ISO-8601 UTC. Deductions consume earliest-expiring record first.

---

## Credits & billing

Tasks consume credits from the account that owns the API key, same as the web UI.

### Cost per ref

| Endpoint | Credits | Notes |
|---|---|---|
| `/verify/v2` (depth=1)  | 1 / ref  | Per citation; a 10-reference text = 10 credits. |
| `/verify/v2` (depth=5)  | 5 or 1 / ref | 1 if a fast first pass already finds the ref authentic (deep skipped), otherwise 5; see below. |
| `/verify/v2` (depth=20) | — | _Closed beta, not enabled for public API._ |
| resume (`taskHash`) | 0 | Replays cached state. |

Latest machine-readable rates: `GET /credit-rules` (unauthenticated).

### Deep verification billing

At `depth=5`, each reference is first checked with a fast verification pass (the
same one used at `depth=1`). If that pass already finds the reference
**authentic**, it's billed just **1 credit** and deep verification is **not** run.
References the fast pass can't confirm go through the full deep verification and are
billed **5**.

- The per-ref `cost` in `ref_completed` and the `task_completed.cost` /
  `balance` are authoritative — read them for the exact charge.
- A single-ref upgrade via `refHash` **always** runs (and bills) the full deep
  verification — 5 credits — regardless of what a fast pass would have found.

### How it's reported

- Each `ref_completed.data.cost` = the cost of that ref (in credits).
- `task_completed.cost` = sum of `ref.data.cost` for non-exceeded refs. No overhead fees.
- Resuming reports the same `cost` but doesn't re-deduct — use `balance` (in `task_completed` or `GET /credits`) for real remaining credits.
- Cancelling (closing the socket): you're charged only for references that finished, at their actual cost; references that didn't complete are not charged.

### Running out

If credits run out mid-task, remaining refs get `assessment: "exceeded"` and the task still ends with `task_completed` (not `error`). Buy more on <https://citetrue.com/pricing> and re-submit the un-verified references.

---

## Error reference

| Status | Cause | Fix |
|---|---|---|
| `400` | Malformed JSON or body > 10MB. | Check request body. |
| `401` | Missing / invalid / revoked API key. | Issue a new key on Dashboard. |
| `405` | Wrong HTTP verb. | `/verify/v2` is POST, `/credits` is GET. |
| `429` | Rate limit exceeded on this API key. | Sleep `Retry-After` seconds and retry. |

Once the SSE stream is established (`200 OK`), per-task failures surface as `error` events in-stream rather than HTTP status codes.

---

## Versioning & stability

- Breaking changes ship as a new path (e.g. `/verify/v3`). Current `/verify/v2` URL contract is frozen: fields may be added, never removed or retyped.
- SSE envelope `{event, data, error, warn}` and the set of event names are stable.
- New `ref.assessment` values may appear (future verification tiers). Treat unknown values as "don't know" rather than erroring.
- New `notices[]` tokens may appear as more mismatch classes are detected.
- Migration notes ship via email — subscribe via contact.
