Zotero integration contracts (verified 2026-07-07)
zotgo talks to a running Zotero 7+ desktop app over two HTTP surfaces on
localhost:23119. It never touches zotero.sqlite. This file is the verified
reference for both surfaces; everything below was captured against a live
Zotero (client 9.0.4, Local API v3, schema 42, ~8784047 user, 13 groups,
2203 items in My Library).
The two surfaces:
| Surface | Path prefix | Purpose | Default state |
|---|---|---|---|
| Local API (read) | /api/* | read library data, server-side export | OFF by default (httpServer.localAPI.enabled) |
| Connector API (write) | /connector/* | create items/attachments, snapshots | on whenever Zotero runs |
Both require Zotero to be running. This is the central constraint: zotgo has no headless/offline mode.
1. Local API (read) — /api/*
Web-API-v3-compatible. Same JSON envelope and headers as api.zotero.org, so
the online API docs (_reference/zotero-upstream/zotero-docs/content/dev/web_api/)
apply. Served by server_localAPI.js.
Availability / probe
GET /api/→200 "Nothing to see here."when reachable. This no-op is the version-agnostic probe (it ignores API-version mismatch).- When the pref is off, every
/api/*route except/api/returns403 text/plain "Local API is not enabled". zotgo must detect this exact case and tell the user how to enable it (Settings → Advanced → "Allow other applications on this computer to communicate with Zotero" + Local API). - Connection refused → Zotero not running.
Response envelope
Every item/collection object:
{
"key": "HRAC4E44",
"version": 3579,
"library": { "type": "user", "id": 8784047, "name": "My Library", "links": {…} },
"links": { "self": {…}, "alternate": {…}, "attachment": { "href": ".../items/CWEW5DNC", "attachmentType": "application/pdf", "attachmentSize": 585858 } },
"meta": { "creatorSummary": "Posten", "parsedDate": "2009-06", "numChildren": 1 },
"data": { "key": "…", "version": 3579, "itemType": "journalArticle", "title": "…", "creators": [...], "tags": [...], "collections": [...] }
}
datais polymorphic byitemType. Decode as a typed envelope withData json.RawMessage; unmarshal fields on demand.metagives server-derived fields pyzot rebuilt by hand from SQL:creatorSummary,parsedDate,numChildren. Free.links.attachmentinlines the primary attachment (type + size) on a top-level item — no extra round trip to know it has a PDF.
Response headers (pagination + versioning)
Total-Results: 2203
Last-Modified-Version: 3579
Link: <…?limit=1&start=1>; rel="next", <…?start=2202>; rel="last"
Zotero-Schema-Version: 42
- Pagination: follow
Link rel="next"or page withstart/limit. - Counts are free:
GET …?limit=1and readTotal-Results(this is howzot statsshould count without pulling rows). Last-Modified-Versionenables cheap change-detection / caching later.
Route map (from server_localAPI.js)
Item routes exist under both /api/users/:userID/… and /api/groups/:groupID/…:
/api/users/:userID/items list all items
/api/users/:userID/items/top top-level only (parents)
/api/users/:userID/items/trash trashed
/api/users/:userID/items/tags tags in item set
/api/users/:userID/items/:itemKey one item
/api/users/:userID/items/:itemKey/children attachments + notes (NOT annotations — see below)
/api/users/:userID/items/:itemKey/file 302 redirect to the local file URL
/api/users/:userID/items/:itemKey/file/view/url local file URL
/api/users/:userID/items/:itemKey/fulltext full-text content (per item)
/api/users/:userID/collections[/top] collections (tree via parentCollection)
/api/users/:userID/collections/:key one collection
/api/users/:userID/collections/:key/items[/top] items in a collection
/api/users/:userID/collections/:key/collections subcollections
/api/users/:userID/searches/:searchKey/items saved-search results
/api/users/:userID/tags all tags
/api/users/:userID/groups groups (list)
/api/groups/:groupID one group
Schema surface (no library id needed):
/api/ no-op probe / version
/api/schema full Zotero schema (200)
/api/itemTypes [{itemType, localized}, …]
/api/itemFields
/api/itemTypeFields
/api/itemTypeCreatorTypes
/api/creatorFields
Query parameters
limit,start— pagination.q+qmode=titleCreatorYear|everything— search (verified:q=algae&itemType=journalArticle→ 3;q=photobioreactor&qmode=everything→ 2).itemType=journalArticle(supports||/-boolean per web API).itemKey=KEY1,KEY2— restrict to specific keys (verified live 2026-07-10). On/itemsZotero also returns the matched items' children; on/items/topit returns exactly the requested keys. An emptyitemKey=is ignored, not treated as "match nothing".format=json|bibtex|csljson|ris|biblatex|mods|tei|rdf_*|csv|…— server-side export via Zotero's translators (all verified 200 live: bibtex, csljson, ris, biblatex, mods, tei, rdf_bibliontology, Zotero-native csv). Expose these generically through oneformat=passthrough, not one method per format. Note the Zotero-nativecsvdiffers from zotgo's hand-rolled summary csv.include=bib,citationandstyle=also apply.sort,direction.
Export page-boundary behavior (verified live 2026-07-10, Zotero 9.0.4)
Confirmed against a real 1093-item library by forcing limit=1 over an
itemKey=-scoped query. Previously inferred from document structure only.
- bibtex / biblatex / ris — one record per entry; pages concatenate cleanly.
- csljson — each page is a JSON array; they splice into one array.
- Zotero-native
csv— the full header row is repeated on every page, so header-dedupe is correct. Each page is also prefixed with a UTF-8 BOM (EF BB BF). Go'sencoding/csvdoes not treat the BOM specially: it becomes part of the first field, and the quote that follows reads as a bare quote in an unquoted field, so parsing fails outright. Strip the BOM per page and re-emit one on the merged document (Zotero emits it so spreadsheets detect UTF-8). A BOM may also legitimately appear inside a field value — only strip the leading one. - mods / tei / rdf_* — each page is a complete document wrapping its records
in a single root (
<modsCollection>,<listBibl>,<rdf:RDF>); mods and tei additionally emit an<?xml?>declaration per page. Concatenation is therefore never valid; multi-page requests must be refused. - Zotero's CSV translator skips standalone attachments: a library with 1093 top-level items (19 of them standalone attachments) exports 1074 CSV rows. That omission is Zotero's, not a merge defect.
Annotation writes are field-order sensitive
Verified live 2026-09-12, Zotero 10.0.2 (schema 44). Creating an annotation
requires annotationType to arrive before the other annotation* fields.
Anything else first is rejected:
400 annotationType must be set before other annotation properties
This is a trap for Go in particular: encoding/json sorts map keys, and
annotationType sorts after annotationColor, annotationComment,
annotationPageLabel, annotationPosition and annotationSortIndex. A
map[string]any payload therefore always fails, while a struct — which
encodes in declaration order — always works. The ordering is a property of the
type, so encode annotations from a struct.
annotationPosition is also required. Omitting it fails a NOT NULL constraint in
Zotero's own schema, and the error arrives as raw SQL including the statement and
its bound parameters — which is not something to relay to a user.
Both facts were found by seeding a corpus, not by reading: the first attempt reported creating two annotations and created none, because a batch write reports per-object rejections inside a 200 response rather than as an error.
/children omits annotations unless you ask for them
Verified live 2026-09-12, Zotero 10.0.2 (schema 44). An unfiltered
/items/:key/children never includes annotations. They appear only with an
explicit itemType filter.
Annotations hang off an attachment, not off the item, so seeing this takes two levels. For a journal article with two attachments, one of them annotated:
GET /items/24PYAE9Q/children -> 2 items (both attachments)
GET /items/P2XTLVZU/children -> 0 items
GET /items/P2XTLVZU/children?itemType=annotation -> 1 item
Walking the item gives you two attachments and no indication that either carries an annotation. The annotation exists, belongs to that attachment, and returns 200 when fetched directly — it is absent only from the unfiltered listing.
AllRawAnnotations in internal/zotero/annotation.go already passes
ItemType: "annotation" and is therefore correct; the risk is any new caller
that enumerates children generically and assumes it has seen everything.
This matters most for anything that copies or mirrors an item: enumerate children unfiltered and every annotation is silently dropped, with the result looking complete. Ask for annotations explicitly, or state plainly that they are not carried.
The /file endpoint = a storage-path simplification, not byte streaming
GET /items/:key/file returns a 302 redirect to the attachment's local
file:// URL; GET /items/:key/file/view/url returns the same local file URL
as text/plain. zotgo does not need to know the Zotero data-dir /
storage/<key>/ layout that pyzot resolved by hand, but attachment access is
"ask Zotero for the file URL, then open/read that path" rather than a plain HTTP
byte stream. Do not assume a normal HTTP client following redirects will fetch
the bytes correctly.
Library-id / user-vs-group routing (correctness-critical)
userID = 0is accepted and resolved to the logged-in user (responses report the real id, e.g.8784047). Groups use their real numeric id.GET /api/users/0/groups→[{id, version, meta:{numItems}, data:{id,name,description}}].- Restriction (from source): "No access to user data for users other than the local logged-in user." Single-user only; you cannot read another user's personal library.
- zotgo must map its notion of "which library" → either
users/<uid>orgroups/<gid>. Getting this wrong reads the wrong library silently. This is the #1 correctness risk carried over from pyzot's issue-4 analysis.
Local write contract (released in Zotero 10.0)
zotero/zotero#5015 (commits 9dd17a2, 77f2432, a37a9e7; dstillman finished
AbeJellinek's branch) added the local write API, and it ships in Zotero 10.0.
zotgo's write support is live-verified against that build. Writes activate only
when the running Zotero exposes the write endpoints (the Zotero-Server-ID
header is the probe signal); on an older build, write commands fail fast with the
same explanation doctor gives, and --dry-run still works.
Endpoints & methods (mirror Web API v3 write semantics):
POST /api/local/authorize— obtain a local API key (local-only; no web analog). Body{"appName":"zotgo"}→ Zotero modal (Allow / Always Allow / Deny). 200{"key":"<key>","remember":<bool>}; 403{"denied":true}; 400 if appName blank; 429 +Retry-Afterif rate-limited. "Allow" keys are single-use (consumed by the first successful write) → always handle 401 by re-authorizing.- Batch create/delete on the collection routes:
POST/DELETE/…/items,/…/collections(DELETEmulti-key via query param, ≤50). - Per-object:
PUT/PATCH/DELETE/…/items/:key,/…/collections/:key. MAX_WRITE_OBJECTS = 50,MAX_DELETE_OBJECTS = 50.
Auth & headers:
- Writes require the local API key via
Zotero-API-Key(or?key=); missing/bad → 401. Zotero-Server-ID: a stable per-database id on every response. Optional on reads, required on writes (missing → 428 Precondition Required; mismatch → 412 Precondition Failed). Its presence on a read is the clean probe signal that this Zotero has the write API at all.If-Unmodified-Since-Versionguards updates and deletes against the library'sclientVersion(mismatch -> 412 with expected/found). New batch creates have no prior object to guard. File uploads instead useIf-None-Match: *.?since=andIf-Modified-Since-Versiongate reads; writes returnLast-Modified-Versionwhere applicable.
Response shapes (identical to Web API v3, so the parser is shared):
- Batch → 200
{"successful":{"<i>":<obj>}, "success":{…}, "unchanged":{"<i>":"<key>"}, "failed":{"<i>":{"key","code","message"}}}. - Single
PUT/PATCH/DELETE-> 204. File registration -> 204.
Managed attachment files use the API-v3 full-upload contract for an existing
imported_file attachment. zotgo first creates attachment metadata without
filename, path, md5, or mtime, then:
- Authenticated form POST to
/api/.../items/:key/filewith MD5, a bare filename, byte length, millisecond mtime, media type, andIf-None-Match: *.{"exists":1}means Zotero already has those bytes. - When upload is needed, stream the staged bytes without API credentials to the
returned exact same-origin
/api/local/uploads/:uploadKeyURL. The receiver verifies MD5 and returns 201. Redirects are not followed. - Authenticated form POST of
upload=:uploadKeyto the item file route; success is 204.
A focused item read then verifies the parent, imported_file link mode,
requested and actual filename, media type, MD5, and enclosure length. This is a
Local API workflow, not Connector ingestion: it targets an explicit existing
bibliographic parent. zotgo caps each import at 128 MiB while Zotero's Local API
receiver buffers the request before staging it.
Versioning is endpoint-scoped, in Zotero's own words: "Local API versions
have no relation to Web API versions, nor … to local API versions returned by
other Zotero instances." This is exactly zotgo's schema-2 decision to keep
version out of the DTOs — vindicated. clientVersion is a new per-library
counter, incremented once per library per transaction.
The already-shipped Web API provides official remote CRUD today (its writes use the same batch response shape). The durable axis is local vs remote endpoint, not read vs write.
2. Connector API (ingestion) — /connector/*
Served by server_connector.js. Same surface the browser extension uses; always
available when Zotero runs (no pref gate). Writes go through Zotero itself, so
zotero.sqlite integrity is Zotero's responsibility.
Ingestion adapter only, not a general write backend. saveItems writes to
getSaveTarget() — Zotero's currently-selected library/collection — and
silently redirects to My Library when that target is not editable
(verified in source). That nondeterminism disqualifies it for general
automation; use it only for app-mediated workflows (recognition, import,
snapshots). General resource writes belong on the official API write contract.
Registered endpoints (from server_connector.js)
/connector/ping liveness ("Zotero is running")
/connector/detect run detection on a URL
/connector/getTranslators list translators
/connector/getTranslatorCode
/connector/saveItems save translated item metadata → library
/connector/saveSnapshot save a web snapshot
/connector/saveSingleFile single-file snapshot
/connector/saveStandaloneAttachment upload a local file as standalone attachment
/connector/saveAttachment
/connector/saveAttachmentFromResolver
/connector/getRecognizedItem poll: did a saved PDF get recognized into a parent?
/connector/getSelectedCollection current target collection in the UI
/connector/updateSession attach tags/collection to a just-saved session
/connector/import import RIS / BibTeX / CSL text
/connector/installStyle
/connector/proxies /connector/delaySync /connector/getClientHostnames /connector/hasAttachmentResolvers
Write choreography (mined from pyzot's write/)
- Local file →
POST /connector/saveStandaloneAttachmentwith raw bytes,Content-Type, and anX-Metadataheader carrying{sessionID, title, url: file://…}. Response is{canRecognize}in current source; do not assume the new attachment key is returned. - Recognition (PDF → parent metadata): poll
POST /connector/getRecognizedItemwith the sessionID — this is the correct API replacement for pyzot's SQL polling hack (wait_for_recognized_parentreaching into the DB). Current response shape is recognized parent display data (title,itemType), not a Zotero item key. - Tags / collection →
POST /connector/updateSessionwith the sessionID after the save. - Collection/library target →
POST /connector/updateSessionexpects a Zotero tree target (L<libraryID>orC<collectionID>). Resolve explicit--collectionnames throughPOST /connector/getSelectedCollection, whosetargetsarray exposes those IDs for editable libraries/collections. Local API collection keys are not sufficient forupdateSession. - RIS / BibTeX / CSL file →
POST /connector/import(no local parsing). Observed contract below. - Identifier (DOI/arXiv/PMID/ISBN) → resolve identifier to item JSON, then
POST /connector/saveItems. The connector does not resolve identifiers for you headlessly; pyzot supplied its own resolvers. - Session model: every write flow generates a client
sessionIDand threads it through save → recognize → updateSession. - Existing-item attachments/collection assignment: connector attachment and session-update operations are session-bound. Attaching a file to an arbitrary pre-existing item, or assigning an arbitrary existing item to a collection, was handled in pyzot with direct SQLite writes. zotgo rejects that path; these capabilities are out of scope unless Zotero exposes an API for them.
Import contract (verified live 2026-09-11, Zotero 10.0.1, schema 44)
Re-probed on Zotero 10.0.2 (2026-09-11): the session parameter, the 500 on a
missing Content-Type, the silent duplicate creation, and the response undercount
are all unchanged. The remaining details below were recorded on 10.0.1 and were not
re-checked.
The bullet above — "POST /connector/import (no local parsing)" — was mined from
pyzot and was the whole of what we knew. Everything in this subsection was
observed by driving the endpoint against a running Zotero.
POST /connector/import?session=<unique-id>
Content-Type: application/x-bibtex
<the bibliography file as the raw request body>
session is a query parameter, and it is not sessionID. Every other
connector flow threads a sessionID through the JSON body; import takes
?session= on the query string, because its body is the file itself. A repeated
or omitted value fails with 409 {"error":"SESSION_EXISTS"}, so generate a
fresh id per call.
Content-Type must be present but need not be correct. Omitting the header
returns HTTP 500, not a 4xx. The value is not authoritative either: RIS sent
as text/plain imported correctly, so Zotero sniffs the content. Send a
plausible type; never rely on it being honoured.
BibTeX, RIS and CSL-JSON all import.
Status codes and response
| Outcome | Status | Body |
|---|---|---|
| Items imported | 201 | JSON array of the created top-level items |
| Parsed, nothing found | 201 | [] |
| Input unparseable | 400 | empty |
session reused or missing | 409 | {"error":"SESSION_EXISTS"} |
Content-Type absent | 500 | empty |
Three traps follow from that table:
201does not mean anything was imported. An empty file is a successful import of zero items, so a caller that checks only the status reports success.- A
400carries no message at all. There is nothing to relay; the caller has to supply the entire explanation itself. - The response under-reports what was created. A six-entry
.bibreturned six items and created seven — a BibTeXnote = {…}field becomes a childnoteitem, which does not appear in the response array. Counting the array is not counting the writes.
Returned items carry "version": 0. That is not a usable object version and must
not reach a DTO (see the endpoint-scoped versions rule in AGENTS.md).
Duplicates are not detected
Importing the identical file twice created a second complete set of items with
new keys — no dedup, no precondition, and nothing in the response indicating a
match. Any safety here has to be zotgo's own, as it already is for
attachment import.
Target
Created items come back carrying collections: ["<key>"] naming the collection
selected in the GUI, so the save target is observable in the result as well as in
advance through getSelectedCollection. Behaviour against a non-editable
target is untested — the probe profile had no group libraries — so the
redirect-to-My-Library claim in the section above remains source-derived rather
than observed.
Type mapping observed
| BibTeX | Zotero | Carried through |
|---|---|---|
@article | journalArticle | DOI, publicationTitle, volume, issue, pages |
@inproceedings | conferencePaper | proceedingsTitle, place, pages |
@book | book | ISBN, edition, publisher |
@patent | patent | patentNumber, country; creators become inventor |
@techreport | report | institution, reportType |
@misc | document | howpublished → extra: "Published: …" |
Braced TeX accents decode correctly (M{\"u}ller → Müller, Fern{\'a}ndez →
Fernández). A braced organisational author
({Department of Mechanical Engineering}) stays a single-field creator rather
than being split into first/last.
No publisher network I/O was observed for this fixture — nothing was fetched and no attachments were created. That is one fixture, not a guarantee.
3. Web API (remote reads) — api.zotero.org (verified live 2026-07-28)
The hosted Web API is API-v3 like the Local API, so the same semantic client serves it — but several contract details differ and were observed against a real key (user 8784047), not inferred:
- No
/apiprefix; real user id. Routes are/users/<id>/…and/groups/<id>/…. The<id>is the key owner's real numeric id (from/keys/current), not the Local API's0sentinel. - Auth.
Zotero-API-Key: <key>header (the documented current form; the?key=query param is deprecated and would leak the key into logs). GET /keys/currentreturns{"userID", "username", "access":{"user":{…}, "groups":{…}}}. Theaccessgrants (library,write,files,notes) are the endpoint's own statement of what the key may do — so web capabilities are probe-derived, unlike the local write capability, which no probe can determine. A 403/404 here means the key is missing/revoked/wrong.- csljson is wrapped differently. The Local API returns a bare array
[…]per page; the Web API returns{"items":[…]}. zotgo unwraps both to one bare CSL-JSON array, sozot export csljsonoutput is endpoint-neutral. This bit us: the httptest fakes served the local bare-array shape, so the merge passed every unit test and failed on the first real web export — the live web suite caught it (cf. the CSV-BOM finding). - Pagination differs for scoped exports. An
itemKey=-scopedformat=csljsonquery returns all matches in one page (limitis ignored;Total-Resultsequals the match count, noLinknext), where the Local API paginates the same query one item per page. The unwrap-and-splice merge handles both. - Rate limiting (Web-only):
429/503carryRetry-After: <seconds>(honored with bounded retries); aBackoff: <seconds>header asks the client to slow down (honored between paginated pages). The Local API never sends these.
4. What this buys zotgo over pyzot (concrete)
- No private-schema coupling (pyzot pinned SQLite schema v107).
- Reads while Zotero is running (pyzot's WAL lock effectively wanted it closed).
meta.*server-derived fields for free (creatorSummary/parsedDate/numChildren).- Server-side export (
format=bibtex|csljson) → nopybtexreimplementation. - Attachment file URLs via
/file→ no storage-dir path resolution. - Recognition via
/connector/getRecognizedItem→ no DB polling hack.
5. What it costs
- Zotero must be running for everything.
- Local API is off by default → first-run enablement friction.
- Single logged-in user only.
- Identifier→metadata resolution is not provided by the local surfaces.