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:

  1. GET /api/search — find the work and keep the returned record.
  2. POST /api/works/:id/acquire — queue a job, posting that record back.
  3. GET /api/works/:id/assets — poll until the artifacts are ready, then download.
EndpointPurposeMetered
GET /api/searchBlended ranked searchon cache miss
POST /api/works/:id/acquireQueue an acquisition jobyes
GET /api/works/:id/assetsJob + per-artifact statusno
GET /api/works/:id/file/:kindStream pdf / tei / mdno
GET /api/works/:id/file/figures/:slugStream a cropped figureno
GET /api/works/:id/citationRender a citationno
GET /api/jobs/:idJob rowno
GET /api/healthLivenessno

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 402 with insufficient_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.

OperationFree per rolling 24hPrepaid cost
search1001¢
acquire202¢

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" }
  • 400Missing q, an unknown source, a bad citation format, 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.

Acquire

POST/api/works/:id/acquiremetered

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

FieldDefaultDescription
formatsall fourAny 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_contentfalseSkip 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

GET/api/works/:id/assetsfree tier

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

GET/api/works/:id/file/:kindfree tier

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.

KindContent typeFilename
pdfapplication/pdf<id>.pdf
teiapplication/tei+xml<id>.tei.xml
mdtext/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

GET/api/works/:id/file/figures/:slugfree tier

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

GET/api/works/:id/citation?format=free tier

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

GET/api/jobs/:idfree tier

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

GET/api/healthfree tier
{ "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.

FieldTypeNotes
idstringCanonical work id — see below.
openalex_idstring?Present when the work resolved onto OpenAlex.
doistring?Normalised, lowercase, no doi.org prefix.
titlestring
authorsstring[]Display names, in order.
yearnumber?
venuestring?
cited_bynumber?
is_oaboolean?
abstractstring?
provenancestringopenalex, scholar or both.
ranksobject1-based rank held in each source list; the input to fusion.
scorenumber?Fused score. Absent in native order.
biblioobject?volume, issue, first_page, last_page.
pdf_candidatesarray{ url, source } in priority order.
has_contentobject?OpenAlex first-party availability: { pdf, grobid_xml }.
data_cidstring?Scholar cluster id, stable per paper.
external_urlstring?Landing page, shown when we cannot serve bytes.
licensestring?

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.