Bindery exposes a REST API at /api/v1/*. Every Bindery feature is reachable from the API — the React UI uses the same endpoints. There is also a small /api/queue surface that mimics the Sonarr/Radarr queue contract for external tooling.
The handler list below is a representative selection. The router lives in
cmd/bindery/main.goand registers over 100 endpoints; that file is the source of truth.
Every request to /api/v1/* is authenticated except the bootstrap and identity endpoints:
GET /api/v1/healthGET /api/v1/auth/statusPOST /api/v1/auth/login,/auth/logout,/auth/setupGET /api/v1/auth/oidc/{provider}/loginand/callback
A request is allowed if any of the following holds:
- Auth mode is Disabled (configured in Settings → General → Security).
- Auth mode is Local only and the request originates from a private-range IP —
10/8,172.16/12,192.168/16,127/8, IPv6 ULA, link-local, loopback. - The request carries a valid
X-Api-Keyheader (or?apikey=query parameter) matching the stored key. - The request carries a valid
bindery_sessioncookie. - Auth mode is Proxy and a trusted upstream forwards
X-Forwarded-Usermatching a Bindery account (see auth-proxy.md).
Otherwise the server returns 401. Browser sessions also need a CSRF double-submit token on mutating requests (POST / PUT / DELETE); API-key clients are exempt from CSRF.
Non-browser clients (curl, scripts, mobile apps) authenticating via API key do not need to send an X-Requested-With: bindery-ui header — that header is required only for browser sessions to satisfy the CSRF gate. The auth endpoints listed above (/auth/login, /auth/logout, /auth/setup, /auth/status, /auth/csrf) are exempt from the X-Requested-With check entirely, since there is no session to protect at that stage.
The API key lives in Settings → General → Security. Regenerating it invalidates every existing consumer.
GET /api/v1/author list authors (paginated, filterable)
POST /api/v1/author add an author (triggers async book fetch)
POST /api/v1/author/book add one book by foreign id (creates its author if needed)
POST /api/v1/author/bulk bulk add/update
GET /api/v1/author/{id} author detail
PUT /api/v1/author/{id} update monitored / metadata profile
DELETE /api/v1/author/{id} remove (with optional file delete)
POST /api/v1/author/{id}/refresh re-pull works from OpenLibrary
GET /api/v1/author/{id}/catalogue-reconciliation
preview stale metadata-only Wanted rows
POST /api/v1/author/{id}/catalogue-reconciliation
recheck and remove selected preview rows
GET /api/v1/author/{id}/relink-upstream/candidates
search metadata candidates for manual relink
POST /api/v1/author/{id}/relink-upstream re-bind to a different foreign ID
GET /api/v1/author/{id}/aliases list merged-in alias rows
POST /api/v1/author/{id}/merge merge another author into this one
POST /api/v1/author/book answers 409 Conflict when foreignBookId (or
the canonical id it resolves to on the ISBN path) already matches a book in the
requesting user's library (#1227). "In the library" is scoped exactly like
GET /api/v1/book: rows the user owns plus rows with no owner, and every row
for an admin or when tenancy is off. The check runs before any author creation
or provider fetch, so a conflict has no side effects, and the existing row is
left exactly as it was: an unmonitored book stays unmonitored. The body mirrors
the Add Author conflict:
{
"error": "book already in your library; change its format or monitoring from the book page",
"existingBookId": 42,
"existingBook": { "id": 42, "foreignBookId": "OL27448W", "title": "The Hobbit", "monitored": false }
}To change the format or monitoring of a book you already have (for example an
imported ebook you now also want as an audiobook), update it through
PUT /api/v1/book/{id} or the book page rather than re-adding it.
With tenancy enforced, a non admin user whose request reaches a copy of the same foreign id held by a different user also gets 409, with no row in the body because the caller may not see it:
{ "error": "book is held by another user" }GET /api/v1/author/{id} includes a lastSync object when this Bindery process
has synced the author's catalogue since it started — what the provider returned
and what each filter dropped, so a catalogue that was filtered down is
distinguishable from an author who wrote that few books:
{
"lastSync": {
"completedAt": "2026-08-11T12:00:00Z",
"total": 66,
"added": 1,
"skippedLanguage": 65,
"skippedJunk": 0,
"skippedMediaType": 0,
"allowedLanguages": ["eng"],
"unknownLanguageFail": true,
"skippedLanguageSample": [{ "title": "Les Ours", "language": "fre" }]
}
}It is held in memory, not stored, so it is absent after a restart until the
author is synced again. skippedLanguageSample is capped at a few titles; the
counts are exact.
POST /api/v1/author responses include a providerMismatch object when the
linked record routes its catalogue syncs to a provider other than the
configured metadata.primary_provider (#2237), so a fallback pick is visible
instead of silent:
{
"providerMismatch": {
"primaryProvider": "hardcover",
"linkedProvider": "openlibrary"
}
}The field is absent when the providers match or no primary is configured.
Catalogue reconciliation is deliberately separate from refresh. The GET route
queries the current primary provider without using its cached author catalogue
and returns candidates, a reason-count summary, protection counts, and
providerComplete. A partial provider result never treats absence as a reason
to remove a row. The POST route accepts the IDs from the preview:
{ "bookIds": [12, 19] }The server recomputes the preview and deletes only IDs that are still
candidates and still match the database-level guard: same author, status
wanted, not excluded, no legacy file-path columns, and no book_files rows.
It returns an applied summary with requested, deleted, and skipped.
Neither route removes files from disk.
POST /api/v1/author/{id}/relink-upstream may be called without a body for
automatic upstream matching. Manual relink can send:
{
"foreignAuthorId": "hc:example-or-dnb:123",
"authorName": "Selected Candidate Name"
}The automatic form answers 503 when the provider configured as
metadata.primary_provider did not respond and the only candidates came from a
fallback provider. Nothing is written in that case. The foreign ID a relink
stores is what every later catalogue sync reads the provider back off, so a
link written while the primary was rate limited would silently and permanently
move that author onto the fallback (#2271). Retry once the primary is
answering, or send an explicit foreignAuthorId, which skips the search
entirely.
GET /api/v1/book?status=wanted filter by status (wanted, imported, skipped)
POST /api/v1/book/bulk bulk monitor / status flip
GET /api/v1/book/{id} book detail (with editions, history, formats)
PUT /api/v1/book/{id} update monitor / status / metadata
DELETE /api/v1/book/{id} remove from library
DELETE /api/v1/book/{id}/file delete imported file(s) on disk (`?format=ebook|audiobook` scopes to one format; `?path=…` deregisters one tracked path WITHOUT deleting anything on disk)
PUT /api/v1/book/{id}/exclude exclude from future searches
POST /api/v1/book/{id}/rebind re-link to a different metadata record
POST /api/v1/book/{id}/enrich-audiobook pull narrator/duration/cover from Audnex
POST /api/v1/book/{id}/search manual indexer search
GET /api/v1/book/{id}/file download the imported file (auth required; `?path=…` serves one specific tracked file, for a book holding several of a format; `?format=ebook|audiobook` picks the format on dual-format books; `?path=` wins when both are sent)
GET /api/v1/search/author?term=… metadata author search
GET /api/v1/search/book?term=… metadata book search
GET /api/v1/search/library?q=… your own catalogue: `{authors, books, series}`, up to `limit` rows per group (default 5, max 10), owner scoped like the list endpoints; 400 on an empty `q`
GET /api/v1/book/lookup?isbn=… | ?asin=… single-book lookup by identifier
GET /api/v1/wanted/missing list wanted-but-missing books
POST /api/v1/wanted/bulk bulk operations on wanted
Metadata results say when they are already in the caller's library (#1227).
Each /search/book and /book/lookup result carries libraryBookId when its
foreignBookId matches a book the requesting user owns, and each
/search/author result carries libraryAuthorId when its foreignAuthorId
matches a library author visible to the user, by primary id or by an alternate
identifier. Both fields hold the library row id and are omitted otherwise, so a
client can offer "open" for those rows and "add" for the rest. The lookup is
owner scoped: another user's copy does not stamp. If the library lookup itself
fails the search still succeeds, unstamped.
[
{ "foreignBookId": "OL27448W", "title": "The Hobbit", "libraryBookId": 42 },
{ "foreignBookId": "OL27479W", "title": "The Silmarillion" }
]GET /api/v1/indexer list configured indexers
POST /api/v1/indexer add (admin)
PUT /api/v1/indexer/{id} update (admin)
DELETE /api/v1/indexer/{id} remove (admin)
POST /api/v1/indexer/{id}/test probe connectivity
GET /api/v1/indexer/search?q=… multi-indexer ad-hoc query
GET /api/v1/search/last-debug last query plan & raw responses (debugging)
GET /api/v1/prowlarr list registered Prowlarr servers
POST /api/v1/prowlarr add a Prowlarr server
POST /api/v1/prowlarr/{id}/sync import indexers from Prowlarr
GET /api/v1/rootfolder list library roots
POST /api/v1/rootfolder add a new root
DELETE /api/v1/rootfolder/{id} remove
dailyQueryLimit on an indexer caps how many requests Bindery will send it in a
rolling 24 hours (#2312). Omitted, null and 0 all mean no cap. A negative
value is rejected with 400. GET /indexer and GET /indexer/{id} also return
dailyQueriesUsed on capped indexers, which is a display figure summed from the
stored hourly buckets and lags the live tally by up to one flush interval.
Indexer and Prowlarr responses never carry the stored credential. Every
endpoint that returns one of these objects sends apiKey as an empty string
and adds a read-only apiKeyConfigured boolean saying whether a key is stored:
{
"id": 3,
"name": "NZBGeek",
"url": "https://api.nzbgeek.info",
"apiKey": "",
"apiKeyConfigured": true
}apiKeyConfigured is response only. It is not persisted, and sending it is
ignored.
On PUT /api/v1/indexer/{id} and PUT /api/v1/prowlarr/{id} the key follows
the same rules the import-list endpoints already use:
| Request body | Result |
|---|---|
apiKey omitted |
the stored key is kept |
"apiKey": "" |
the stored key is kept |
"apiKey": "newkey" |
the stored key is replaced |
"clearApiKey": true |
the stored key is removed |
"apiKey": "newkey" with "clearApiKey": true |
400, nothing is changed |
Keeping the stored key on a blank value is what lets a client read an object,
edit an unrelated field, and send it straight back without wiping the
credential. It also means a blank submit on a Prowlarr instance no longer
cascades an empty key to every indexer synced from it. Removing a key is
therefore always deliberate: it takes clearApiKey.
Setting a key still works normally on create, and POST /api/v1/indexer/test
still accepts an inline apiKey so an unsaved configuration can be probed.
To test a saved indexer against its stored key, use
POST /api/v1/indexer/{id}/test.
GET /api/v1/downloadclient list (filtered by visibility)
POST /api/v1/downloadclient add (admin)
GET /api/v1/downloadclient/{id} fetch one (admin)
PUT /api/v1/downloadclient/{id} update (admin)
DELETE /api/v1/downloadclient/{id} remove (admin)
POST /api/v1/downloadclient/{id}/test probe connectivity (admin)
POST /api/v1/downloadclient/test probe an unsaved config (admin)
GET /api/v1/queue active downloads with live downloader overlay
POST /api/v1/queue/grab submit a search result to the download client
POST /api/v1/queue/{id}/retry-import retry an importFailed item without re-downloading
DELETE /api/v1/queue/{id} remove (also from the download client)
?deleteFiles=true have the client destroy the data too
?removeFromClient=false forget Bindery's row only, leave the torrent/NZB in the client
POST /api/v1/queue/bulk-delete remove many; {"ids":[..],"deleteFiles":false,"unmonitorBooks":false,"removeFromClient":true}
GET /api/v1/pending grabs awaiting delay-profile clearance
POST /api/v1/pending/{id}/grab promote pending to queue immediately
GET /api/v1/queue/manual-import/lookup parse + catalogue-match one path (admin)
GET /api/v1/queue/manual-import/scan enumerate + match book units under a folder (admin)
POST /api/v1/queue/manual-import import one path against a book (admin)
POST /api/v1/queue/manual-import/batch import selected {path, bookId} pairs (admin)
POST /api/v1/queue/manual-import/reassign move a mis-matched file to another book (admin)
GET /api/v1/queue/manual-import/reassign/preview where that reassign would move and rename it (admin)
?path=…&targetBookId=N[&format=ebook|audiobook]
POST /api/v1/queue/manual-import/match attach an importFailed download to a book and import its files (admin)
GET /api/v1/reorganize/preview preview renaming tracked files to the current template (admin)
?scope=book|author|library (&id=N for book/author)
POST /api/v1/reorganize/apply move the selected files {fileIds:[…]} to their templated paths (admin)
GET /api/v1/history grab / import / failure timeline
POST /api/v1/history/{id}/blocklist add the release to the blocklist
GET /api/v1/blocklist list blocked releases
DELETE /api/v1/blocklist/{id} remove an entry
DELETE /api/v1/blocklist/bulk bulk remove
Every download client response blanks apiKey and password and reports
whether one is stored through two extra booleans, so a stored secret is never
handed back to a caller:
{
"id": 3,
"name": "qBittorrent",
"type": "qbittorrent",
"username": "admin",
"apiKey": "",
"password": "",
"apiKeyConfigured": false,
"passwordConfigured": true
}PUT /api/v1/downloadclient/{id} decodes over the stored row, so any key you
leave out keeps its stored value rather than being reset. That applies to the
credentials too:
apiKeyabsent, or"": keeps the stored API key.apiKey: "new-value": replaces the stored API key.clearApiKey: true: removes the stored API key.apiKey: "new-value"together withclearApiKey: true: rejected with400, because the two contradict each other.
password and clearPassword behave the same way. Because an empty string now
means "keep", moving a client between a password type and an API-key type has
to send the clear flag for the credential it is abandoning, otherwise the old
secret stays on the row.
Booleans follow the same rule: omitting enabled or useSsl leaves them as
they were, and an explicitly sent false still turns them off.
POST /api/v1/downloadclient/test probes a config without saving it. Include
the saved client's id and the handler fills in a credential you left blank
from that row, but only while type, host, port, useSsl and urlBase
still match it. Point the probe anywhere else and it runs with whatever
credential the body carried.
GET /api/v1/notification list webhooks
POST /api/v1/notification create
POST /api/v1/notification/{id}/test fire a test event
POST /api/v1/backup snapshot the SQLite database (optional {"label": "..."})
GET /api/v1/backup list stored backups
DELETE /api/v1/backup/{filename} delete one backup
POST /api/v1/backup/{filename}/restore stage a backup for the next restart (admin, X-Confirm-Restore: true)
GET /api/v1/system/status version, uptime, build info
PUT /api/v1/system/loglevel runtime log-level switch (debug/info/warn/error)
GET /api/v1/images?url=<encoded> proxied + cached cover image (30-day TTL)
Every event POSTs a JSON body with a consistent shape so relays render it without a custom template:
| Field | Meaning |
|---|---|
eventType |
grabbed | bookImported | upgrade | downloadFailed | health | test — present on every event |
title |
what happened, e.g. Release Grabbed, Book Imported, Download Failed |
message |
the subject, e.g. The Way of Kings · Brandon Sanderson |
body |
alias of message (Apprise requires a body field) |
item |
the raw release/book name (the title before it was moved into message) |
format |
ebook | audiobook on bookImported and upgrade. Omitted for Apprise targets only (a URL with a /notify path segment) — Apprise reserves format for the body markup and rejects anything but text/html/markdown with HTTP 400. Every other consumer still receives it |
mediaFormat |
the same value as format, always present. Use this one if your relay is Apprise, or if you want a key that is never stripped |
| event extras | author, size, path, status, clientId when relevant |
ntfy: set the notification's topic field and point the URL at the ntfy
server root (e.g. https://ntfy.sh). Bindery then POSTs the JSON body with a
topic field to the root, which ntfy renders natively. Without a topic it POSTs
to the URL as-is, so a topic URL would show the raw JSON — use the topic field
or ntfy message-templating headers (X-Title, X-Message) instead.
GET /api/v1/setting list stored settings
GET /api/v1/setting/{key} read one stored setting
PUT /api/v1/setting/{key} write one setting (admin), body {"value": "..."}
DELETE /api/v1/setting/{key} delete one setting (admin)
GET /api/v1/settings/descriptors describe every key Bindery knows (admin)
Secrets (*.api_key, *.api_token, auth.*, and the rest of
isSecretSetting) never appear in a list or a read, and settings whose value is
a server filesystem path are returned to admins only.
PUT refuses a key Bindery does not know, with 400 and the key named.
Before this, an unrecognised key was stored and then read by nothing, so a typo
such as serch.interval saved, reported success and silently did nothing
forever. Reads and deletes are unchanged, so a row written by a different build
is still listed and can still be removed.
GET /api/v1/settings/descriptors is the list of keys that will be accepted. It
carries no stored values, only the shape of each key:
| Field | Meaning |
|---|---|
key |
the settings key |
type |
string | bool | int | duration | enum |
default |
what Bindery behaves as if the key held when it is unset |
values |
accepted values, for enum keys |
min, max |
bounds, for int and duration keys |
description |
one line explaining what the key does |
restartRequired |
true when the key is read once at startup, so a change is stored now and takes effect at the next restart |
state |
active (something reads it), internal (Bindery writes it as its own bookkeeping, not a knob), inert (accepted and stored and read by nothing) |
secret |
the value is never returned, not even to an admin |
adminOnly |
only an admin may read the value |
writable |
PUT /api/v1/setting/{key} accepts this key |
A key marked inert is kept so existing rows and existing clients keep working.
Do not offer it as a control: nothing will happen.
GET /api/v1/auth/status public — am I logged in?
GET /api/v1/auth/csrf fetch a CSRF token for browser flows
POST /api/v1/auth/login username + password
POST /api/v1/auth/logout
POST /api/v1/auth/setup first-run admin creation (one-shot)
PUT /api/v1/auth/mode switch enabled/local-only/disabled/proxy (admin)
POST /api/v1/auth/password change own password
POST /api/v1/auth/apikey/regenerate rotate the API key
GET /api/v1/auth/oidc/providers list configured providers
PUT /api/v1/auth/oidc/providers update providers (admin)
GET /api/v1/auth/oidc/{provider}/login start an OIDC login
GET /api/v1/auth/oidc/{provider}/callback OIDC redirect target
GET /api/v1/auth/users list users (admin)
POST /api/v1/auth/users create (admin)
DELETE /api/v1/auth/users/{id} delete (admin)
PUT /api/v1/auth/users/{id}/role change role (admin)
PUT /api/v1/auth/users/{id}/reset-password reset (admin)
GET /api/queue Sonarr/Radarr-style queue payload
This endpoint sits outside /api/v1/ and matches the queue contract used by Harpoon and similar *arr-aware tools. It returns totalRecords, supports pagination and sort, and surfaces per-record size, sizeleft, status, client, remote ID, and protocol. API-key authentication is required; browser-session CSRF protections do not apply.
Bindery serves an OPDS 1.2 catalogue at /opds/:
/opds/— catalog root/opds/recent— recently imported/opds/authorsand/opds/authors/{id}— by author/opds/seriesand/opds/series/{id}— by series/opds/book/{id}— book entry/opds/book/{id}/file— download the book file/opds/images?url=<encoded>— cover images, cached locally
Cover links in the feed point at /opds/images rather than at the metadata
provider, so reading apps fetch every cover from your instance. It is the same
handler and the same <dataDir>/image-cache/ the web UI uses via
/api/v1/images, mounted a second time inside /opds because that route
requires a session cookie or the API key and reading apps authenticate with
HTTP Basic. See third-party-data.md for why covers are
served this way.
OPDS authenticates via HTTP Basic — any username, API key as the password. KOReader, Moon+ Reader, Aldiko, and other OPDS-capable apps work out of the box.
Add an author by OpenLibrary ID:
curl -X POST -H "X-Api-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"foreignAuthorId":"OL23919A","monitored":true,"searchOnAdd":true}' \
http://bindery:8787/api/v1/authorList wanted books for a specific author:
curl -H "X-Api-Key: $KEY" \
"http://bindery:8787/api/v1/book?status=wanted&authorId=42"Trigger a manual search and inspect what the indexer returned:
curl -X POST -H "X-Api-Key: $KEY" http://bindery:8787/api/v1/book/123/search
curl -H "X-Api-Key: $KEY" http://bindery:8787/api/v1/search/last-debugSnapshot the database before an upgrade:
curl -X POST -H "X-Api-Key: $KEY" http://bindery:8787/api/v1/backupThe body is optional. Pass a label to make the snapshot identifiable — it is
appended to the filename as bindery_<timestamp>_<label>.db:
curl -X POST -H "X-Api-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"label":"pre-upgrade"}' http://bindery:8787/api/v1/backupThe label is sanitised server-side before it reaches the filename: only
A-Za-z0-9_- survive, every other character (including /, \, . and any
non-ASCII) collapses to -, and the result is capped at 40 characters. A label
that sanitises to nothing — an all-CJK label, for example — is dropped and the
snapshot keeps the plain timestamp name.
Restore a snapshot:
curl -X POST -H "X-Api-Key: $KEY" -H 'X-Confirm-Restore: true' \
http://bindery:8787/api/v1/backup/bindery_20260101_120000_pre-upgrade.db/restoreAdmin only, and the X-Confirm-Restore: true header is required. A 200 means
the backup passed its integrity check and is staged, not that it is live:
Bindery runs SQLite in WAL mode, so writing the running database out from under
its own write-ahead log would replay stale pages over the restored ones.
The file is copied to <database>.restore-pending and swapped in on the next
start, so restart Bindery to finish the restore. A backup that is not a sound
SQLite database is rejected with 400 and nothing is staged.
Fire a test webhook:
curl -X POST -H "X-Api-Key: $KEY" \
http://bindery:8787/api/v1/notification/1/testWhen Bindery is mounted under a path prefix (e.g. https://example.com/bindery), set BINDERY_URL_BASE=/bindery. All route prefixes — including /api/v1, /api/queue, and /opds — are served under that base, and the embedded React SPA emits matching URLs. See DEPLOYMENT.md for full details.