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/callbackGET /api/v1/auth/csrfGET /api/v1/auth/oidc/providers(the read path only;PUTon the same path is an admin mutation)
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 matching the stored key. The?apikey=query parameter is accepted too, but only onGET,HEADandOPTIONS: a key in a URL leaks into proxy logs, browser history andReferer, so it cannot authorise a mutation. Mutations must send the header. - 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 (anything other than GET, HEAD and OPTIONS); 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 profile and works, skipping the metadata cache; 409 while a sync for that author is running
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.
The same response carries "syncInProgress": true while a catalogue sync for
the author is running in this process, and omits it otherwise. The author page
polls it after Refresh metadata to show the result once the sync is done,
and POST /api/v1/author/{id}/refresh answers 409 Conflict while it is set
instead of starting a second sync (#2601).
POST /api/v1/author accepts an optional monitorNewItems (#2541), the same
field PUT /api/v1/author/{id} takes: all (the default) lets a refresh add
newly discovered books, none keeps the catalogue as it was added. Any other
value is rejected with 400.
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.
GET /api/v1/author/{id}/relink-upstream/candidates returns author records
from every configured provider, minus the one the author is linked to right now.
Records the author was linked to previously are returned with
"previouslyLinked": true rather than being hidden, so a relink can be undone
(#2688). The field is omitted on every other candidate.
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/series list series with their linked books
GET /api/v1/series/{id} one series
POST /api/v1/series/{id}/fill add the series' missing books as wanted (admin)
PATCH /api/v1/series/{id} monitor / unmonitor (admin)
GET /series returns the bare array it always has. Pagination is opt-in
(#2345): pass limit and/or offset and you get an {items, total, limit, offset} envelope instead, which is what a large catalogue wants, since the
unpaged response carries every series with every linked book.
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 (admin)
GET /api/v1/indexer/{id} fetch one (admin)
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 a saved indexer (admin)
POST /api/v1/indexer/test probe an unsaved config posted in the body (admin)
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 (admin)
GET /api/v1/prowlarr/{id} fetch one (admin)
POST /api/v1/prowlarr add a Prowlarr server (admin)
PUT /api/v1/prowlarr/{id} update (admin)
DELETE /api/v1/prowlarr/{id} remove (admin)
POST /api/v1/prowlarr/{id}/test probe connectivity (admin)
POST /api/v1/prowlarr/{id}/sync import indexers from Prowlarr (admin)
GET /api/v1/rootfolder list library roots
POST /api/v1/rootfolder add a new root (admin)
DELETE /api/v1/rootfolder/{id} remove (admin)
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.
When Bindery is holding off on an indexer after a rate limit (#2640),
GET /indexer and GET /indexer/{id} carry cooldownUntil (a timestamp) and
cooldownReason. Both are response-only, are omitted when nothing is held, and
are ignored if a client sends them. The hold lives in memory, so it is gone
after a restart.
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 (admin)
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/{id}/diagnose path doctor: ordered checks with a fix each (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.
POST /api/v1/downloadclient/{id}/diagnose runs an ordered list of checks
against a saved client and answers 200 with the result. It takes the id
only, never a path, and it runs only when called.
{
"clientType": "qbittorrent",
"checks": [
{"code": "config", "status": "pass", "message": "The saved settings are usable."},
{"code": "connect", "status": "pass", "message": "Connected to qBittorrent."},
{"code": "category", "status": "pass", "message": "qBittorrent has the category \"books\"."},
{"code": "client_path", "status": "pass", "message": "Completed downloads land in \"/torrents/books\", from the category save path."},
{"code": "remap", "status": "pass", "message": "No path remap applies, so Bindery looks for \"/torrents/books\" at the same path."},
{"code": "local_path", "status": "fail", "message": "Bindery would look for completed downloads in \"/torrents/books\", which is outside every folder it is configured to use (download folder \"/downloads\"). Bindery did not look inside it.", "fix": "Add a path remap on this client ..."},
{"code": "hardlinks", "status": "skipped", "message": "Skipped because an earlier check failed."},
{"code": "indexer_reach", "status": "unknown", "message": "Bindery cannot test whether the download client can reach your indexers, trackers or Usenet servers.", "fix": "..."}
],
"paths": [{"clientPath": "/torrents/books", "source": "the category save path", "remapRule": "none", "localPath": "/torrents/books"}],
"hardlinks": [],
"primaryFix": "Add a path remap on this client ..."
}codeis stable:config,connect,category,client_path,remap,local_path,hardlinks,indexer_reach.messageandfixare English sentences.statusispass,warn,fail,skippedorunknown. Every check after afailisskippedwithout running, exceptindexer_reach, which is alwaysunknownbecause Bindery cannot test the client's own route to indexers.- The folder checked is where a grab actually lands, worked out the way the grab itself is sent: the save path Bindery sends (rTorrent always, qBittorrent without a category, Transmission with an absolute category), the category save path (qBittorrent, with an empty one meaning the default save path plus the category name), the category folder (SABnzbd), the category DestDir or DestDir plus the category name when
AppendCategoryDiris on (NZBGet), a Deluge label's move completed path, or the client default.sourcenames which. The category is used exactly as a grab sends it: a category with spaces around it, or a Deluge category with capital letters (the Label plugin only accepts lowercase labels, so such grabs are not labelled), givesclient_path: warn. - Ebook and audiobook grabs are checked separately, because each resolves its own category and download folder. When both land in the same folder there is one
pathsrow and theclient_path,remapandlocal_pathchecks have nomediaType; otherwise each carriesmediaTypeebookoraudiobook. remapRuleisclient(this client's path remap changed the path),global(BINDERY_DOWNLOAD_PATH_REMAPdid) ornone. A Windows drive path fails for a missing remap only when Bindery itself is not running on Windows. When no remap applies and the client's folder is not under any folder Bindery uses,remapiswarnandlocal_pathcarries the failure and the fix. When Bindery sends the save path itself (rTorrent, qBittorrent without a category) and only the global remap is set,remapiswarn: sending does not apply the global remap, so the client is given Bindery's own folder path and the round trip proves nothing.hardlinkshas one row per download folder and library root pair,{mediaType, downloadPath, root, result, linkable, reason}. Every pair gets a real link probe, because two bind mounts of one filesystem share a device ID yet refuse links across them.resultisyes,no,unknown(Bindery could not write a test file in the download folder) ormissing(the library folder does not exist and was not probed).primaryFixis the fix of the first failure, or of the first warning when nothing failed.
Bindery only looks at the filesystem (a stat, a directory listing for a letter
case mismatch, a temporary write probe and a hardlink probe) when the remapped
path is at or under BINDERY_DOWNLOAD_DIR, BINDERY_AUDIOBOOK_DOWNLOAD_DIR or
a library root, both as written and after following symbolic links. A client
that reports any other folder gets local_path: fail saying so, and nothing
there is touched. A symbolic link in the path that does not resolve is refused
rather than followed. Each filesystem phase has a 10 second deadline; a folder
on a mount that stops answering comes back unknown instead of holding the
request. At most four filesystem phases can be waiting at once across all
requests; past that a check answers unknown saying a previous check is
still waiting for the filesystem. SABnzbd is asked for complete_dir and the one category
only, Transmission for download-dir only, and Deluge for its three download
location keys and the label's options only. NZBGet's config call cannot be
filtered on the server, so its reply is decoded keeping only the folder and
category keys. Error text from a client passes through the same secret
redaction as other outbound errors, and the stored API key and password are
removed from every sentence and path in the response. SABnzbd answers
client_path: unknown rather than a failure when its key is an NZB key, which
cannot read folder settings.
GET /api/v1/notification list webhooks (admin)
POST /api/v1/notification create (admin)
GET /api/v1/notification/{id} fetch one (admin)
PUT /api/v1/notification/{id} update (admin)
DELETE /api/v1/notification/{id} remove (admin)
POST /api/v1/notification/{id}/test fire a test event (admin)
POST /api/v1/backup snapshot the SQLite database (admin, optional {"label": "..."})
GET /api/v1/backup list stored backups (admin)
DELETE /api/v1/backup/{filename} delete one backup (admin)
POST /api/v1/backup/{filename}/restore stage a backup for the next restart (admin, X-Confirm-Restore: true)
GET /api/v1/system/status version, commit, build date, newest published release, image cache size, Hardcover feature state
POST /api/v1/library/scan start a library scan in the background (202)
GET /api/v1/library/scan/status summary of the last library scan, paths included (admin)
GET /api/v1/library/unmatched books the scan could not match, one row per book (admin)
GET /api/v1/library/unmatched/summary pending, ignored and adopted counts plus scan status (admin)
POST /api/v1/library/unmatched/{id}/adopt register the row's files in place against a book (admin)
POST /api/v1/library/unmatched/{id}/undo reverse an adoption exactly (admin)
POST /api/v1/library/unmatched/{id}/ignore set a pending row aside (admin)
POST /api/v1/library/unmatched/{id}/unignore return an ignored row to pending (admin)
POST /api/v1/library/unmatched/ignore ignore pending rows by {"ids":[..]} or {"authorFolder":"..."} (admin)
GET /api/v1/system/logs app log lines (admin)
GET /api/v1/system/logs/export the same rows as a downloadable file (admin)
GET /api/v1/system/loglevel current log level (admin)
PUT /api/v1/system/loglevel runtime log-level switch, debug/info/warn/error (admin)
GET /api/v1/images?url=<encoded> proxied + cached cover image (30-day TTL)
GET /api/v1/library/scan/status returns the stored summary of the most recent
scan: the counts, the library roots it walked and the path of every unmatched
file. That is server filesystem layout, so the route is admin only in the same
way as /system/storage, and a non admin gets 403 (#2361). A 404 means no
scan has run yet. The unmatched files themselves moved to
/library/unmatched: the summary now carries unmatched_units,
ignored_units and units_truncated, and unmatched_files is always an
empty list, kept for one release so an older cached web bundle still parses.
GET /api/v1/library/unmatched lists the books a library scan could not
match. A row is a book: an audiobook folder, a disc set, or same named ebook
files are one row. Query parameters, all optional:
| Parameter | Values |
|---|---|
state |
pending (default), ignored, adopted |
reason |
author_not_in_library, no_candidate_books, no_title_match, no_title_parsed |
authorFolder |
first folder under the library root |
format |
ebook, audiobook |
search |
words matched against title, author and path |
sort, dir |
score (default), title, folder, files, size, seen; asc or desc |
limit, offset |
page, at most 250 rows |
facets |
1 adds grouped counts by reason, format and top author folders |
The response is {items, total, facets?, summary, scan}. Each item carries
id, kind, format, fileCount, sizeBytes, relPath, rootPath,
authorFolder, parsedTitle, parsedAuthor, reason, up to three
candidates ({book, score}, a title similarity from 0 to 1), state, the
adopted book if any, bookCreated, authorCreated and the first 20
members file names. No provider is called to build it.
POST /api/v1/library/unmatched/{id}/adopt takes either {"bookId": 12} for
a book already in the library or {"foreignBookId": "...", "foreignAuthorId": "...", "authorName": "..."} for a metadata result. An optional "format"
must equal the row's format; audio files cannot be adopted as an ebook. No
field is a path. The files are registered in place (an audiobook folder as its
folder, a disc set disc folder by disc folder), nothing is moved and no search
starts. A book added from metadata is created unmonitored with its media type
set to the files' format. Every side effect is recorded on the row before the
next, so an adoption interrupted by a crash is reversed at the next start.
Answers:
| Status | Meaning |
|---|---|
200 |
the updated row |
400 |
neither or both of bookId and foreignBookId, or a format that is not the files' format |
404 |
no such row, or the book is gone |
409 |
the row is not pending (the body names its state), or a file already belongs to a book |
422 |
a file is gone, is not a regular file, or resolves outside the library folders |
502, 503 |
the metadata provider failed or the primary provider is down |
POST .../undo removes exactly the book file entries the adoption made, each
only while it still belongs to the book it was registered to, and a book or
author it created when nothing else holds them (books by the author count
whether excluded or not) and the book has not been used since (monitored,
edited, linked to a series, searched or downloaded). A kept book is reported in
the response's message. The row returns to pending only once every step
succeeded; a 503 means a step failed (a busy database, say), the adoption
is still on record and is completed automatically within a few minutes, and
a 409 means another request took the row before the undo finished. An adopt
that fails and cannot reverse what it did also answers 503 and is cleaned up
the same way. Adopt, undo, ignore and unignore each claim the row with a compare and
swap, so a double click or two admins acting at once get one 200 and one
409.
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 | bookAnnounced | requestCreated | 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 |
requestCreated extras |
kind (book | author), username, mediaType, requestId. title, author and username have control and invisible characters removed, are length capped, and have @, <, >, [ and ] replaced with fullwidth lookalikes, and the :// of any URL broken the same way, so a title or username cannot mention a channel, use a Slack escape such as <!channel>, form a markdown link or autolink a URL. The same request from the same person notifies at most once an hour, and one requester at most 10 times an hour. The event is off on every webhook until its Request toggle is turned on |
Which events a webhook receives is set per notification by onGrab,
onImport, onUpgrade, onFailure, onHealth, onBookAnnounced and
onRequestCreated. The last two default to false, and migrations 087 and
089 set them to false on every notification that existed before them, so an
upgrade sends nothing new until an admin turns them on.
bookAnnounced is sent once per author run when a refresh (manual, bulk,
Refresh all, relink) or scheduled discovery adds books to an author that had
at least one book row, excluded ones included, before the run started. The
first population of a new author, the refill of an author whose books were all
deleted, and the single book add never send it. Two syncs of the same author
that overlap run their writes one after the other, so a work is announced
once.
| Field | Meaning |
|---|---|
title |
New Book Found or New Books Found |
message |
Author: Title one, Title two and N more |
author, authorId |
the author the books were added to |
count |
how many books the run added |
books |
up to 10 entries of {id, title, foreignId, monitored, releaseDate}; releaseDate is YYYY-MM-DD and omitted when unknown; monitored says whether the book will be searched for |
more |
how many added books are not listed |
Titles, the author name and foreign ids come from the metadata provider, which for OpenLibrary is publicly editable. They are stripped of control and bidirectional override characters, collapsed to one line, and capped (200 characters for titles and names, 100 for ids) before they are sent.
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.
authors.discovery.interval is off or a duration from 24h to 720h
(default off). It sets how often each monitored author is checked for new
books by the scheduled discovery job. Unset means off as well, so the job
runs only once a duration is stored, and the value is read on every hourly
tick, so restartRequired is false.
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 instance API key (admin)
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), body {"role": "admin"|"user"|"requester"}
PUT /api/v1/auth/users/{id}/reset-password reset (admin)
The requester role's API, and the admin queue that decides requests. See Requester for what the role can and cannot do.
POST /api/v1/requests ask for a book or an author
GET /api/v1/requests the caller's own requests, newest first (limit, offset)
DELETE /api/v1/requests/{id} withdraw the caller's own pending request
GET /api/v1/requests/library read only library projection (search, limit, offset)
GET /api/v1/requests/queue every user's requests (admin), status=pending|approved|declined|all
GET /api/v1/requests/pending-count {"count": n} for the nav badge (admin)
POST /api/v1/requests/{id}/approve add what was asked for, owned by the requester (admin)
POST /api/v1/requests/{id}/decline decline, body {"reason": "..."} optional (admin)
POST /api/v1/requests takes exactly three fields and refuses any other with
400, from a body of at most 4 KiB:
{"kind": "book", "foreignId": "OL27482W", "mediaType": "ebook"}kind is book or author, foreignId is the provider id a metadata search
returned, and mediaType is ebook, audiobook, both or empty. Bindery
looks the id up at the metadata provider and stores the title and author the
provider reports. Answers:
| Status | Meaning |
|---|---|
201 |
the request, as below |
409 |
already in the library, already requested by this user (pending or declined); the error field says which |
429 |
this user already has requests.max_pending_per_user requests waiting (default 25), or a requester made creates or searches faster than the per user allowance (burst of 20, then one every 3 seconds; Retry-After says when). The cap is checked in the same statement as the insert, so concurrent creates cannot pass it |
502, 404 |
the provider did not answer, or has no such id |
A request as the API returns it:
| Field | Meaning |
|---|---|
id, kind, foreignId, mediaType |
as requested |
title, authorName |
from the provider at request time (for an author request, title is the author's name) |
status |
pending | approved | declined |
declineReason |
the admin's reason, when declined with one |
createdAt, decidedAt |
timestamps |
fulfilled |
approved and on disk: the book imported, or at least one of the author's books |
booksTotal, booksImported |
an approved author request's progress |
username, resultBookId, resultAuthorId |
admin queue only |
GET /api/v1/requests/library returns {items, total, limit, offset} where
each item has only id, title, authorName, series, seriesPosition,
coverUrl, status and formats (the formats with a file on disk). With
BINDERY_ENFORCE_TENANCY on it is scoped like the Books list.
POST /api/v1/requests/{id}/approve takes the admin's choices, all optional:
metadataProfileId, qualityProfileId, rootFolderId,
audiobookRootFolderId, monitorMode, monitorLatestCount and
monitorNewItems apply to an author request; mediaType and searchOnAdd
apply to both kinds. The approval is claimed atomically, so of two concurrent
approvals one adds and the other gets 409, and a running approval renews its
claim so a slow add cannot be taken over. A request whose item reached the
library in the meantime answers 409, and a failed add leaves the request
pending. Unknown fields are refused with 400.
A requester may call only /requests, /requests/{id} (DELETE),
/requests/library, the three metadata searches (/search/author,
/search/book, /book/lookup, rate limited per user), /images (rate limited per user with a larger allowance), /health
and the session routes under /auth. Every other route answers 403 for
that role, and /opds does too.
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. It sits behind the same authentication as /api/v1, so an API key, a session cookie or an auth mode that admits the caller all work. It is a GET, so no CSRF token is needed.
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 with a Bindery username and that account's password, or with the API key in an X-Api-Key header or an ?apikey= query parameter. The key is not accepted as the Basic 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.