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
search100
acquire20

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.
workThe 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.