External APIs¶
ALMa pulls scholarly data from several public sources. Each has its own quotas, identifier shapes, and quirks. This page documents what ALMa fetches, how it batches, and what to expect when a source goes sideways.
Global network control¶
The same application-wide External network access switch is available in
Health → System status and Settings → Connections → External APIs. It
controls outbound scholarly APIs, Slack, email, Google Scholar, and hosted AI.
Turning it off leaves local Library, maps, and local search usable, shows a
persistent warning, and stops requests at the shared transport before any
socket is opened. ALMA_DISABLE_NETWORK=1 is the operations hard override: it
can force access off but cannot force a user's stored off choice on.
Find & Add remains graceful when OpenAlex's paid search pool is drained: paper search omits only the OpenAlex lane and continues Semantic Scholar, Crossref, arXiv, bioRxiv, and Europe PMC; author search falls back to matching local OpenAlex-linked identities. Import review and Health repair cards use the same credit forecast and disable work that the known remaining pool cannot cover.
OpenAlex (primary)¶
OpenAlex is ALMa's primary source for metadata, citations, topics, institutions, and the works graph.
- Endpoints used:
/works,/works/{id},/authors,/authors/{id}. Topics, sources, and institutions arrive embedded viaselect=on/worksand/authors— they are not separate calls. Related works come from/works/{id}withselect=related_works, then a bulk-by-ID fetch of those IDs. - No semantic
/find/works: the natural-language/find/workssemantic call was removed — it 404s and is a separate paid product (~1000 credits/call). Hybrid search is now purely the lexical/works?search=path. - API key — REQUIRED (since 2026-02-13): every request needs
OPENALEX_API_KEY. OpenAlex retired the email "polite pool"; without a key you get 100 free credits/day and then HTTP 409. A free key (openalex.org/settings/api) gives standard limits — 100,000 credits/day (singleton GETs cost 0 credits, list requests 1 each), at a typical ~10 req/s. Set it in.envor via Settings → Connections → OpenAlex. - Contact email (optional):
OPENALEX_EMAILno longer affects rate limits (the polite pool is gone) but still sets a courteous User-Agent and feeds the Crossref polite pool. - Field projection: ALMa always sends a
select=parameter so we only fetch the fields we use. The select list lives inalma/openalex/client.py::_WORKS_SELECT_FIELDS. Adding a downstream consumer of a new field requires updating the select. - Pricing is per request class, and singletons are FREE. This is the fact that decides when to batch:
| operation | cost / 1 000 calls |
|---|---|
| Get singleton (one entity by ID or DOI) | free |
| List + filter | $0.10 |
| Search (full-text) | $1.00 |
| Content download | $10.00 |
A free key is $1.00/day — unlimited singletons, ~10 000 list+filter,
~1 000 search. Source of truth in-repo:
alma/openalex/http.py::_CLASS_COSTS_USD (+ cost_class()), which mirrors
https://developers.openalex.org/api-reference/authentication. Read those
constants rather than copying prices, which drift.
* Batch on latency, not by rule. Because singleton GETs cost nothing,
mechanically replacing them with pipe-filter calls makes things more
expensive, not less. The trade only pays on a hot path: 100 singletons is
~10 s of round trips at $0, whereas one pipe-filter is ~0.3 s at $0.0001
(0.01% of the daily budget). So ALMa batches the interactive per-candidate
author-enrichment loop in openalex/client.py and deliberately leaves the
one-off singleton lookups alone.
* Bulk-by-ID batching: the corpus rehydrator fetches metadata for
many papers in one call via filter=openalex_id:W1|W2|… — up to
100 IDs per filter (the documented OR ceiling) with per-page=100,
splitting on HTTP 400/414 if a URL ever runs long. Worth 1 list credit
instead of N free singletons because the backlog is latency-bound. The
client uses thread-local sessions so the rehydration pipeline can fan
these out concurrently.
* Rate limits: ALMa tracks every response's x-ratelimit-*
headers and exposes them at Settings → External APIs → OpenAlex
usage. If you see "no calls yet", you haven't hit the API yet —
it does not mean we failed to record.
Semantic Scholar¶
Semantic Scholar covers what OpenAlex doesn't: pre-computed SPECTER2 vectors, paper-level recommendations, and the reference/citation graph.
- Endpoints used:
/paper/batchfor bulk metadata +specter_v2vectors./paper/search/bulkfor the recent Feed/monitor ingestion lane;/paper/searchfor interactive/general Discovery./paper/search/matchfor title→paper identity (the corpus title-resolution sweep and author-identity triangulation)./paper/{id}/referencesand/paper/{id}/citationsfor the graph lane./recommendations/v1/papers/forpaper/{id}(single seed, all-time) andPOST /recommendations/v1/papers(positive + negative seeds, new work only) for thes2_relatedrecommendation channel — a different host path than the rest of the graph API./author/batch,/author/search, and/author/{id}/papersfor author identity resolution.
Documented limits (from the live graph/v1/swagger.json)¶
| endpoint | limits |
|---|---|
POST /paper/batch |
500 lookup ids, 10 MB response, 9 999 citations |
POST /author/batch |
1 000 author ids, 10 MB |
GET /paper/search |
limit ≤ 100, ≤ 1 000 relevance-ranked results total, 10 MB |
GET /paper/search/bulk |
1 000 rows/call, no limit/offset parameter, token pagination, 10 M total, no nested data |
GET /paper/search/match |
one closest-title row; 404 on a miss |
GET /paper/{id}/citations · /references |
limit ≤ 1 000 |
GET /author/{id}/papers · /author/search |
limit ≤ 1 000 |
/paper/{paper_id} and its sub-resources accept DOI:<doi>,
CorpusId:<id>, ARXIV:<id>, PMID:, PMCID: — never pre-resolve an
identifier to a paperId with an extra request.
- Field projection is per-endpoint, not global (
ENDPOINT_FIELDS+project_fields()indiscovery/semantic_scholar.py). S2 does not accept the samefieldseverywhere, and an unsupported field is a hard HTTP 400, not a silent drop. Verified live 2026-07-27:
| endpoint | tldr + embedding.specter_v2 |
|---|---|
POST /paper/batch |
accepted |
GET /paper/search · /paper/search/match |
accepted |
GET /paper/search/bulk |
rejected (400) |
POST /recommendations/v1/papers |
rejected (400) |
GET /recommendations/v1/papers/forpaper/{id} |
rejected (400) |
The registry is additive — an endpoint declares what it allows, and an
unregistered endpoint raises rather than inheriting the global list. The
previous subtractive patch (one _BULK_UNSUPPORTED_FIELDS constant) failed
open: POST /recommendations inherited the two rejected fields and returned
HTTP 400 on every call for the life of the lane, hidden behind
if resp.status_code != 200: return []. Non-429 4xx now logs at WARNING.
The trap that makes a registry necessary rather than a probe: the field
error only fires when the query actually matches something. GET /forpaper
with the full field set returns a clean 200 {"recommendedPapers": []} on
from=recent (empty for older seeds) and a hard 400 on from=all-cs.
An endpoint can look field-compatible right up until it starts returning
results.
* Bulk search is two-step (bulk → batch): bulk cannot return tldr or the
SPECTER2 vector, so ALMa requests the bulk-supported subset for breadth,
reranks locally, then issues a single /paper/batch to hydrate only the
slice it keeps.
* Bulk has no relevance ordering. Its sort accepts only paperId,
publicationDate and citationCount, and defaults to paperId:asc — a
sha hash. Left at the default this lane returned the 1 000 lowest-paperId
matches out of millions, deterministically, so a keyword monitor surfaced the
same papers forever. ALMa now sends sort=publicationDate:desc (the pool
means "newest matching work") and reconstructs relevance locally with
core.scoring_math.query_match_score. Note the spec's caveat: records with an
undefined sort value sort last, so date-less papers fall outside the
1 000-row window — accepted for this recent-ingestion lane only.
* year is pushed server-side (year=2020-). It used to be filtered in
Python after downloading all 1 000 rows.
* API key — strongly recommended: set SEMANTIC_SCHOLAR_API_KEY
(free at semanticscholar.org/product/api), in .env or via
Settings → Connections → Semantic Scholar. Without one you share
the anonymous worldwide pool (~5,000 requests / 5 min across all
anonymous clients), so 429s are frequent — they're the root cause of
the multi-minute Discovery graph-lane stalls. With a key you get a
dedicated ~1 RPS.
* /paper/batch has TWO independent budgets, and conflating them is a bug:
* ID budget — 500 lookup ids per call. One paper contributes up to two
(S2 ID + DOI), so a "500 papers" setting emits 1 000 ids.
* Payload budget — 10 MB per response. Measured 2026-07-27 with the
full field set: 18 899 bytes/row, dominated by the 768-d SPECTER2
vector. 500 vector-bearing rows ≈ 9.4 MB, i.e. 94% of the hard cap.
* plan_paper_batch() sizes each request from the requested field
projection (a narrow projection legitimately earns a bigger batch), keeps
a 75% safety margin, and recursively splits on a size refusal. The batch
knob surfaced in Settings reads that same plan via services/eta.py, so
the estimate and the run can no longer disagree.
* Results preserve the request order by lookup id (DOI / S2
ID / OpenAlex ID).
* Compacting the response shifts good papers onto bad IDs and
corrupts state. ALMa preserves the original index.
* null rows in the middle of a response are real — the lookup
didn't match.
* One batched-vector primitive: fetch_vectors_for_identifiers() returns
an IdentifierFetchOutcome (rows keyed by requested id, plus disjoint
terminal_ids / retryable_ids). fetch_papers_batch and
services/s2_vectors are both thin callers — a second resilient-split
implementation is exactly what CLAUDE.md → "fix the primitive" forbids.
* Recommendation pools: GET /forpaper/{id} defaults to from=recent,
which is empty for older seeds (measured: 0/20 results on a 2017 seed vs
20/20 with from=all-cs). ALMa always sends from=all-cs — that lane exists
for foundational/older related work. The POST endpoint has no from
parameter at all and only returns 2025–26 work, so it is a frontier
source, not a general related-papers source.
* Author hydration batches. /author/batch takes 1 000 ids; the sweep
gathers a whole phase's S2 ids into one request instead of one request per
author (at 1 RPS that was one second per author).
* Failure classification:
* Retryable failures (429, 5xx, network) stay retryable —
they don't become terminal "no match".
* Only validation failures (4xx other than 429) split down to
singleton lookups and mark only those papers as
lookup_error.
* search_papers(raise_on_rate_limit=True) surfaces a 429 to its
caller as SemanticScholarBatchError(status_code=429) so the
title-search rescue can defer instead of stamping
terminal_no_match. The legacy default (silent empty list) is
preserved for non-critical callers like interactive search.
* Adaptive throttle: any 429 observed by the shared HTTP client
(core/http_sources.SourceHttpClient) engages a 10-second floor
on the per-request interval for the next 30 seconds. Retries: 5
attempts, jittered exponential backoff capped at 60 seconds. Fresh
429s within the cooldown re-arm the floor. While the cooldown is
armed, Discovery and Feed drop the S2 source for the rest of that
refresh pass (via is_in_adaptive_cooldown()) instead of having
each lane queue behind the floor and wait out its deadline; the
window self-clears after 30s (discovery/source_search.py).
* Terminal statuses for vector fetch: unmatched,
missing_vector, lookup_error, bad_local_doi.
bad_local_doi is set before any HTTP call when the local DOI
fails the registry-shape regex ^10\.\d{4,9}/.+; it never reaches
S2. Terminally-missed papers stay eligible for explicit local
SPECTER2 compute. The trigger
papers_clear_fetch_status_on_id_change (see
api/deps.py:init_db_schema) drops these terminal rows whenever
papers.doi or papers.semantic_scholar_id actually changes — so
a paper hydration step that finds a better DOI re-enters the
fetch pool automatically.
* Title-search rescue: papers that miss /paper/batch get one
/paper/search/match call each — the purpose-built closest-title
endpoint, which returns one row instead of N ranked ones and still carries
abstract + the SPECTER2 vector. This lives in
services/title_resolution.py (not s2_vectors.py, which delegates
to it), capped at TITLE_RESOLUTION_PER_RUN_BUDGET = 500 papers per
run. The first 429 short-circuits the rest of the batch's rescue.
Three contract details that make it not a drop-in for /paper/search:
the envelope is {"data": [row]}; a miss is HTTP 404
(Title match not found), which is a normal outcome, not an error; and
matchScore is unbounded (131.8 on an exact title) and cannot be
requested in fields. Acceptance therefore stays with ALMa's local
Jaccard 0.92 / |Δyear| ≤ 1 contract — never matchScore.
match_paper_by_title() returns the raw S2 row so externalIds and
authors[].authorId survive. application/author_identity needs both, and
ALMa's normalized candidate shape drops them: it flattens authors to a
comma-joined string, so the previous search_papers-based code raised
AttributeError on a.get("authorId") and never extracted a preprint id.
* Identity resolution (OpenAlex-first): the Resolve missing
identity sweep (services/title_resolution.py) handles title-only
papers with no usable identifier. It tries OpenAlex /works?search
first (the larger, cheaper, higher-throughput pool) and only falls
back to S2 /paper/search (1 RPS) when OpenAlex misses — so an
OpenAlex-cold corpus can't blow the S2 quota. An accepted OpenAlex
match fills the full work from that same search response (no
second fetch), while the S2 fallback captures the SPECTER2 vector it
carries. Same Jaccard 0.92 / |Δyear|≤1 contract as the rescue above.
* DOI hygiene (core.utils.canonical_lookup_doi): DOIs sent to
S2 are lowercased, URL-decoded, and stripped of trailing publisher
fragments (/pdf, /full, /abstract, /epdf, /meta). The
match-side bookkeeping uses the same canonical form so case-only
differences round-trip cleanly.
Crossref¶
Crossref is the DOI authority and a metadata fallback when OpenAlex doesn't have a paper.
- Endpoints used:
/works/{doi}— singleton DOI lookup; used by per-paper_hydrate_via_crossrefand other one-off paths./works?filter=doi:DOI1,doi:DOI2,...&rows=50— batched DOI lookup viadiscovery.crossref.fetch_works_by_dois. Phase 2 of the bulk corpus rehydrator uses this to resolve up to 50 DOIs per HTTP call (~50× round-trip reduction at full backlog vs the singleton path).- Polite pool: Set
CROSSREF_MAILTOto identify yourself. They ask for it; honour the request. Crossref retuned its REST limits on 2025-12-01: the list/search path — what ALMa hits via/works?query— is now the stricter ceiling at 3 req/s polite / 1 req/s anonymous. Single-record/works/{doi}is looser (10 req/s polite), but one client serves both paths, so ALMa paces to the search ceiling: 0.34s interval polite / 1.05s anonymous, with concurrency 3 polite / 1 anonymous (core/http_sources.py). - Used as a fallback, not the primary path. Most papers resolve through OpenAlex first.
Europe PMC¶
Europe PMC indexes PubMed/MEDLINE, PMC, preprint servers and Agricola — the life-sciences counterpart to S2's CS-leaning corpus. Free, no key, abstracts inline.
- Endpoint used:
GET /searchwithformat=jsonandresultType=core.coreis the only tier that returnsabstractText;liteis cheaper but every candidate would arrive unscoreable. - Filters are pushed server-side into the query string —
FIRST_PDATE:[YYYY-01-01 TO 3000-12-31]for a year floor andOPEN_ACCESS:yfor OA-only. - Response shape gotcha: the journal title is nested at
journalInfo.journal.title, not a top-leveljournalTitle.authorStringis already display-ready. - Not a metadata authority. OpenAlex and Crossref remain the identifier/metadata sources of truth; this adapter contributes discovery candidates plus the DOI / PMID / PMCID needed to reconcile them.
- Paced at ~5 req/s, 2 concurrent (
core/http_sources.py). EBI publishes no hard per-second figure and asks only that clients be reasonable.
arXiv and bioRxiv¶
- arXiv: ALMa uses arXiv's metadata API to resolve preprints not yet indexed by OpenAlex.
- bioRxiv (also covers medRxiv): same fall-through pattern, but
bioRxiv has no keyword-search endpoint (date-range / DOI /
category only). ALMa pulls a shared recent date window, caches it
per
(server, interval)for 300s so every keyword monitor/lane in one refresh shares a single network pull, then filters and re-ranks locally per query (discovery/biorxiv.py). - Abstract recovery (task 05): both servers' structured APIs
also back-fill a missing abstract on a paywalled paper that has an
arXiv/bioRxiv preprint twin — arXiv's Atom
summary(by id, or a title-fieldti:"…"search) and bioRxiv's/details/{server}/{doi}abstract. This runs ahead of any landing-page HTML scrape (cleaner, paywall-free), is fill-only, and stamps a per-source ledger reason (services/preprint_abstract.py). bioRxiv is direct-DOI/URL only — it has no keyword endpoint, so the title-search fallback is arXiv-specific.
These are read-only fall-through paths. Each has its own DOI prefix that triggers the preprint↔journal dedup engine:
| DOI prefix | Source |
|---|---|
10.48550/arXiv.* |
arXiv |
10.1101/* |
bioRxiv / medRxiv |
10.31234/* |
psyArxiv (OSF) |
10.31219/* |
OSF |
10.26434/chemrxiv* |
chemRxiv |
10.20944/preprints* |
MDPI Preprints |
Google Scholar (scholarly)¶
The scholarly package is opt-in and used only for author
identity resolution as a tiebreaker — never for primary metadata
fetches.
- When it's used: when OpenAlex / S2 don't disambiguate an author and you've added them by name.
- When it's not used: anything that can be answered by OpenAlex.
You can leave
scholarlyuninstalled and most flows still work. - Stability warning: Scholar has no public API. The library
scrapes; expect occasional rate-limiting / breakage when Google
changes the page. Set
SCHOLAR_RETRY_DELAYSto tune backoff.
OpenAI¶
OpenAI is optional and currently used as an embedding provider. Configure
it in Settings → Intelligence → AI provider and store the key in
.env or the secret store. See AI capabilities.
Slack¶
Slack Web API, bot-token based.
- Token:
SLACK_TOKEN, or the secret store (slack.bot_token). - Outbound channel:
SLACK_CHANNEL, orslack_channelin settings. - Inbound (capture) channel:
slack_inbox_channel, Settings only.
ALMa uses slack-sdk through ONE client, alma.slack.client.SlackNotifier:
alerts post with chat.postMessage (Block Kit), capture reads with
conversations.history and acknowledges with reactions.add. No webhooks, and
no second client — see External integrations.
Identifier reference¶
Common ID shapes you'll see across the API:
| Shape | Source | Example |
|---|---|---|
| OpenAlex Work ID | OpenAlex | W2123456789 |
| OpenAlex Author ID | OpenAlex | A2123456789 |
| Semantic Scholar paper ID | S2 | 649def34f8be52c8b66281af98ae884c09aef38b |
| Semantic Scholar corpus ID | S2 | 12345678 |
| DOI | Crossref / authority | 10.1038/nature12373 |
| ORCID | ORCID | 0000-0002-1825-0097 |
| arXiv ID | arXiv | 2401.12345 |
All five are stored on the relevant rows when known. Identity resolution (authors and papers) tries to fill in as many as possible.