API reference
Everything the web UI does is a public HTTP call. Search blends OpenAlex and Google Scholar into one ranked list; acquisition pulls the PDF, GROBID TEI, Markdown and cropped figures for a single work and stores them for later streaming.
Overview
The base URL is https://search.researchcake.com. Every path below is
relative to it, and the same routes exist on
http://localhost:8787 under npm run dev.
Requests and responses are JSON (application/json) except the file and
citation endpoints, which return the artifact itself. There are no required headers
for anonymous use, and no versioning prefix — the API is at /api/*.
The shape of a session is always the same three steps:
GET /api/search— find the work and keep the returned record.POST /api/works/:id/acquire— queue a job, posting that record back.GET /api/works/:id/assets— poll until the artifacts are ready, then download.
| Endpoint | Purpose | Metered |
|---|---|---|
| GET /api/search | Blended ranked search | on cache miss |
| POST /api/works/:id/acquire | Queue an acquisition job | yes |
| GET /api/works/:id/assets | Job + per-artifact status | no |
| GET /api/works/:id/file/:kind | Stream pdf / tei / md | no |
| GET /api/works/:id/file/figures/:slug | Stream a cropped figure | no |
| GET /api/works/:id/citation | Render a citation | no |
| GET /api/jobs/:id | Job row | no |
| GET /api/health | Liveness | no |
Authentication
Optional. Send a bearer token to spend against a prepaid key instead of the anonymous free quota:
Authorization: Bearer <your-key>
Three caller types are recognised, in this order:
- Owner — the token matches the deployment's
APP_TOKEN. All metering is bypassed and usage is recorded at zero cost. - API key — an active prepaid key. Each billable call debits its balance; an empty balance returns
402withinsufficient_balance. - Anonymous — no token. The free daily quota applies, keyed on a hash of your IP (the raw address is never stored).
An unrecognised bearer token is not an error — the request simply falls through to the anonymous quota.
Quotas & cost
Only calls with real upstream cost are metered. Reading artifacts you already acquired is always free, however many times you do it.
| Operation | Free per rolling 24h | Prepaid cost |
|---|---|---|
| search | 100 | 1¢ |
| acquire | 20 | 2¢ |
Search results are cached, and a cache hit is served without charging: repeating a query, or paging through one you already ran, costs nothing. Only a miss that actually spends an upstream call is metered.
An acquire that finds a job already running for the work returns that job with
"reused": true and is likewise not charged.
Errors
Errors are JSON with an error field, sometimes joined by a
hint or detail:
{ "error": "free search quota exhausted", "hint": "use an API key or retry tomorrow" }
400Missingq, an unknownsource, a bad citationformat, or no valid formats in an acquire body.402Free quota exhausted or prepaid balance too low.404Unknown work, job or figure — or an artifact that was never stored.502Both search sources failed upstream.
A full walkthrough
Search, acquire the first hit, wait for it, then save the PDF and the BibTeX:
BASE=https://search.researchcake.com
# 1. Search, and keep the first result record.
curl -s "$BASE/api/search?q=retrieval+augmented+generation+evaluation" \
| jq '.results[0]' > work.json
ID=$(jq -r .id work.json)
# 2. Queue an acquisition, posting the record back as `work`.
curl -s -X POST "$BASE/api/works/$ID/acquire" \
-H 'content-type: application/json' \
-d "$(jq -n --slurpfile w work.json \
'{formats:["pdf","tei"], work:$w[0]}')"
# 3. Poll until every asset settles.
until curl -s "$BASE/api/works/$ID/assets" \
| jq -e '[.assets[].status] | all(. == "ready" or . == "failed" or . == "skipped")' >/dev/null
do sleep 3; done
# 4. Download.
curl -s -o paper.pdf "$BASE/api/works/$ID/file/pdf"
curl -s "$BASE/api/works/$ID/citation?format=bibtex"
Step 2 is what persists the work. Citation and asset lookups only know about works that have been through an acquire at least once.
Search
Queries both sources, matches the Scholar hits onto OpenAlex records, and fuses the
two lists with reciprocal rank fusion. If one source fails or is not configured, the
surviving source is returned in its own native order with a warning and
"ranking": "native".
Query parameters
| Name | Default | Description |
|---|---|---|
| q | required | The query string. |
| source | all | all, openalex or scholar. Pinning one source skips fusion and returns that engine's own order. |
| page | 1 | 1-based page over the cached fused list — never a fresh upstream call. |
| per_page | 20 | Page size. |
Response
{
"query": "retrieval augmented generation evaluation",
"page": 1,
"per_page": 20,
"total": 57,
"ranking": "rrf", // "native" when only one source answered
"sources_used": ["openalex", "scholar"],
"cached": false,
"warnings": [],
"dedup": {
"matched_by_doi": 4,
"matched_in_page": 11,
"matched_via_catalog": 3,
"scholar_only": 2
},
"results": [ /* Work objects, see below */ ]
}
total is the size of the fused list, which is bounded by how deep we
fan out into each source — it is not an estimate of the whole literature. Paging
past the end returns an empty results array.
Acquire
Queues a workflow that resolves a PDF (free open-access link first, then the stored OpenAlex copy, then Scholar's direct link), runs GROBID for TEI, and — when requested — converts to Markdown and crops the figures named by the TEI coordinates. Returns immediately; poll assets for progress.
Body
| Field | Default | Description |
|---|---|---|
| formats | all four | Any of "pdf", "tei", "md", "figure". Unknown values are dropped; an empty result is a 400. |
| work | — | The search-result record. Required unless the work is already stored or the id is a bare OpenAlex W… id we can look up. |
| prefer_openalex_content | false | Skip the free links and go straight to the paid OpenAlex content endpoint. |
curl -X POST "$BASE/api/works/W2741809807/acquire" \
-H 'content-type: application/json' \
-d '{"formats":["pdf","tei","md","figure"]}'
Response
202 when a job was started:
{
"job_id": "b1f0…",
"work_id": "W2741809807",
"status": "queued",
"formats": ["pdf", "tei"],
"paid_via": "free_quota" // or "owner" | "api_key"
}
200 with "reused": true when a job for that work is already
in flight.
A reused job keeps the formats it was created with — the second request's
formats are ignored. To add a format, wait for the job to settle and
then acquire again.
Assets
Per-artifact status and download URLs, plus the latest job. This is the polling endpoint; every 3 seconds is a reasonable cadence.
{
"work_id": "W2741809807",
"title": "Deep learning for…",
"job": { "id": "b1f0…", "status": "running", "current_step": "tei" },
"assets": [
{
"kind": "pdf", // pdf | tei | md | figure
"status": "ready", // pending | running | ready | failed | skipped
"source": "oa_link", // where the bytes came from
"bytes": 1843200,
"error": null,
"updated_at": "2026-08-04 11:20:31",
"url": "/api/works/W2741809807/file/pdf"
}
],
"figures": [
{
"id": "W2741809807:figure-1",
"label": "Figure 1",
"caption": "Overview of the pipeline",
"page": 3,
"source": "grobid",
"url": "/api/works/W2741809807/file/figures/figure-1"
}
]
}
You are done when every asset status is ready, failed or
skipped and the job is complete or
errored. skipped means the step could not run — no
open-access source, or a converter that is not configured — and
error carries the reason. The url is null
until the artifact is ready; figures carry their own URLs.
Files
Streams a stored artifact. :kind is pdf,
tei or md — anything else is a 400, and an
artifact that was never stored is a 404.
| Kind | Content type | Filename |
|---|---|---|
| application/pdf | <id>.pdf | |
| tei | application/tei+xml | <id>.tei.xml |
| md | text/markdown | <id>.md |
Responses carry an ETag and a one-day cache-control, and
honour Range and If-None-Match — so resumable downloads and
304s work as usual. content-disposition is
inline with a real filename, which keeps PDFs previewing in the browser
while still naming the file correctly when saved.
Figures
Streams one cropped figure as PNG. The slug is the trailing segment of the figure
id from the assets response; use the url given there
rather than building it yourself.
curl -s "$BASE/api/works/$ID/assets" \ | jq -r '.figures[] | select(.url) | .url' \ | while read -r u; do curl -sO --output-dir figs "$BASE$u"; done
Citation
Renders a citation from stored metadata — no upstream calls, so it is free and
cacheable. format is one of bibtex (default),
apa, mla, chicago, csl-json.
The response body is the citation itself, not JSON-wrapped. When the metadata came
from Scholar alone and some fields had to be guessed, the response carries
x-best-effort: true.
curl -s "$BASE/api/works/W2741809807/citation?format=apa"
404 means the work is not stored yet. Acquire it once — that is what
writes the metadata — and the citation becomes available.
Jobs
The raw job row, for when you have a job_id but not the work. Most
clients should poll assets instead, which reports the job
alongside the artifacts it produced.
{
"id": "b1f0…",
"work_id": "W2741809807",
"status": "complete", // queued | running | complete | errored
"current_step": "assemble",
"started_at": "2026-08-04 11:19:58",
"last_seen": "2026-08-04 11:21:04",
"finished_at": "2026-08-04 11:21:04"
}
Health
{ "ok": true, "service": "research_friend" }
Work object
One canonical paper after the two sources have been merged. This is what
/api/search returns and what you post back as work.
| Field | Type | Notes |
|---|---|---|
| id | string | Canonical work id — see below. |
| openalex_id | string? | Present when the work resolved onto OpenAlex. |
| doi | string? | Normalised, lowercase, no doi.org prefix. |
| title | string | |
| authors | string[] | Display names, in order. |
| year | number? | |
| venue | string? | |
| cited_by | number? | |
| is_oa | boolean? | |
| abstract | string? | |
| provenance | string | openalex, scholar or both. |
| ranks | object | 1-based rank held in each source list; the input to fusion. |
| score | number? | Fused score. Absent in native order. |
| biblio | object? | volume, issue, first_page, last_page. |
| pdf_candidates | array | { url, source } in priority order. |
| has_content | object? | OpenAlex first-party availability: { pdf, grobid_xml }. |
| data_cid | string? | Scholar cluster id, stable per paper. |
| external_url | string? | Landing page, shown when we cannot serve bytes. |
| license | string? |
To decide up front whether a work is worth acquiring: if
pdf_candidates holds nothing from oa_link or
searchapi and has_content.pdf is false,
there is no source to download and the job can only fail. That is exactly the
check behind the "no open-access PDF" badge in the UI.
Work ids
Ids are derived from the strongest identifier available, so the same paper keeps the same id across searches:
W2741809807— the OpenAlex id, whenever the work is in OpenAlex.doi_<sha1>— a DOI with no OpenAlex record.cid_<data_cid>— a Scholar-only work with a cluster id.t_<sha1>— last resort: title, year and first-author surname.
Only the first form can be acquired without posting the record, since it is the only
one we can look back up. Always encodeURIComponent the id when building
a path.