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).
Rules 1 and 2 also require the host name the request was sent to (the Host header, plus X-Forwarded-Host from a trusted proxy) to be one a site on the internet cannot have: an IP address, localhost, a single label name such as bindery or nas, a name under .local, .lan, .home.arpa, .internal or .localdomain, the host of BINDERY_OIDC_REDIRECT_BASE_URL, or a name listed in BINDERY_ALLOWED_HOSTS. Any other name gets 403 with an error naming that variable, and GET /api/v1/auth/status reports authenticated: false with hostNotAllowed: true and the name in refusedHost. The OPDS feed applies the same rule to its own mode grants and answers a refused name with its usual 401 Basic challenge. An API key or a session is accepted under any name. This stops a web page from reaching Bindery through the visitor's browser by pointing its own DNS name at Bindery's address (DNS rebinding). See DEPLOYMENT.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.
The Calibre bridge routes under /bridge/v1 are outside all of this: they take only the Calibre plugin key as a Bearer token, in every auth mode. See Calibre bridge (pull).
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}/duplicate-candidates
read-only groups of titles that look like the same book (#1970)
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, indeterminateRows, a reason-count summary,
protection counts, and providerComplete. Each indeterminateRows entry has a
bookId, title, metadataProvider, and display-only reason. These rows are
informational: they are kept because evidence is incomplete and are never
deletion candidates. Stable indeterminate reasons are language_unknown,
language_evidence_lookup_failed, edition_evidence_unavailable,
partial_catalogue, and unmatched_cross_provider. A partial provider result
never treats absence as a reason
to remove a row. A complete result can make an absent same-provider row a
candidate, but an unmatched row from another provider is kept as indeterminate:
provider migration alone is not deletion evidence. Explicit profile rejections
still apply when the row can be correlated across providers. 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/author/{id}/duplicate-candidates reports groups of the author's
books whose titles look like the same book, for human review (#1970). It is
read-only: it never writes, and the review UI's only action is the existing
PUT /book/{id}/exclude. Titles are compared with an aggressive fold (case,
punctuation, and diacritics ignored; & expanded to "and"), and a pair joins a
group when any of these match: alnum-equal (identical after the fold),
article-strip (identical after dropping a leading article),
edition-suffix (identical after dropping a trailing edition marker), or
substring (one title is the whole main title or subtitle of the other, split
at a colon, bracket, or spaced dash, with length guards; "Foundation" does not
match "Foundation and Empire"). substring is additionally suppressed when
both books are known, different positions in the same series, so "Mistborn"
at position 1 does not match "Mistborn: The Well of Ascension" at position 2.
Groups are linked transitively, so A≈B and B≈C lands in one group.
The response is:
{
"authorId": 42,
"count": 1,
"groups": [
{
"key": "themartian",
"rules": ["article-strip", "substring"],
"books": [
{ "id": 101, "title": "The Martian", "excluded": false, "rules": ["article-strip"] },
{ "id": 102, "title": "Martian", "excluded": true, "rules": ["article-strip"] }
]
}
]
}Each books entry is the full book object plus a per-book rules list; the
group's rules is the union across its members. Groups whose members are all
excluded are omitted, and count is the number of groups returned.
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/book/{id}/calibre where the book stands in the Calibre delivery queue (see Calibre below)
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)
GET /api/v1/qualityprofile list quality profiles
GET /api/v1/qualityprofile/{id} read one
POST /api/v1/qualityprofile create (admin)
PUT /api/v1/qualityprofile/{id} update (admin)
DELETE /api/v1/qualityprofile/{id} remove (admin); 409 while an author uses it
A profile is {id, name, items, cutoff, upgradeAllowed}. items is an array
of {quality, allowed} in preference order, best first (#2733). The media
type of each entry is derived from its token on the server (epub, azw3,
pdf and the other ebook containers on one side; m4b, mp3, flac, m4a,
ogg on the other), so one array holds an ebook list and an audiobook list
and only the relative order inside each kind matters. The web editor writes
the ebook entries first and the audiobook entries after them; any interleaving
is accepted and stored as sent, and a read returns the order that was written.
An unticked entry (allowed: false) never makes a release eligible and never
counts in the ranking. A release is judged and ranked only on the formats of
the media type being searched, so an audiobook carrying a PDF booklet is
judged on its audio token for an audiobook search and on its pdf token for an
ebook one. A media type with no entries at all is not constrained: any format
of that kind is accepted and ranked by the built in order, which
docs/User-Guide-Wiki.md names. A token Bindery
does not recognise is stored and ignored. Duplicates, an empty items and a
list with nothing allowed are refused with 400. cutoff and
upgradeAllowed are accepted and stored but read by nothing.
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.
Three optional overrides are applied to a torrent grabbed from the indexer:
| Field | Meaning | Accepted values |
|---|---|---|
seedRatio |
stop seeding at this upload ratio | a ratio, or -1 for unlimited |
seedTimeMinutes |
stop seeding after this many minutes in total (#2206) | 1 to 5256000 |
inactiveSeedTimeMinutes |
stop seeding after this many minutes without upload (#2206) | 1 to 5256000 |
Omitted on PUT keeps the stored value, and null clears it so the download
client's own rule applies. A seed time below 1 or above 5256000 (ten years) is
rejected with 400. qBittorrent applies all three; Transmission the ratio and the
inactive time; Deluge the ratio only; rTorrent none of them. A limit the client
cannot apply is logged at debug and skipped.
seedRatioSource and seedTimeSource are read only provenance: prowlarr
when a Prowlarr sync filled the value from that indexer's seedRatio or
seedTime, user when the value was sent on create or the indexer has been
updated with PUT since, and absent when unset. Anything a client sends in
either source field is ignored. A Prowlarr sync never overwrites a user value, including a value
cleared to null. Prowlarr has no inactive seed time, so that field has no
source.
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
-> {"items":[..],"partial":true,"staleClients":[{"clientId":1,"name":"qBit","message":".."}]}
partial means a download client did not answer in time, so items is short
POST /api/v1/queue/grab submit a search result to the download client
a guid a recent search returned is grabbed from the server's
record (raw URL) for every caller; otherwise only the API key
uses the posted nzbUrl, and everyone else gets 400 "this search
result has expired, search again"
POST /api/v1/queue/{id}/retry-import retry an importFailed/importBlocked item without re-downloading
POST /api/v1/queue/{id}/retry re-send a failed item's release to the download client (no re-search)
POST /api/v1/queue/bulk-retry retry many; {"ids":[..]}; per id {"ok":true,"action":"import"|"resend"}
DELETE /api/v1/queue/{id} remove (also from the download client, unless another queue item
still uses the same torrent/NZB: then only this row goes)
?deleteFiles=true have the client destroy the data too (same exception)
?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); audio files for
the same book import as one audiobook under one download id
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
Search, queue, pending and grab responses strip every credential parameter
from nzbUrl, guid and infoUrl: the indexer apikey, jackett_apikey,
passkey, torrent_pass, authkey, rsskey, tp, token, signature and
the rest of the list in internal/httpsec/redact.go, plus r inside a
newznab getnzb link. Parameters separated by ; count as well as &.
Credentials in the URL itself (https://user:pass@host/...) are replaced,
user name included. Magnets lose their tr= announce URLs and their xs= and
as= source URLs.
nzbUrl and guid also lose path segments shaped like a private tracker
passkey, RSS key or download token. A path is cut into runs of A-Z a-z 0-9 _ -
(compared after percent decoding), and a run is redacted when it is
| Shape | Example |
|---|---|
| hex of 16 or more characters with a letter and a digit | /download/123/0a1b2c3d4e5f60718293a4b5c6d7e8f9/x.torrent |
20 or more characters where one piece between - and _ switches between letters and digits at least 4 times |
/torrent/download/12345.aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW1xY |
the value of a passkey= style pair in the path |
/rss/passkey=hunter2/feed.xml |
Release names switch between letters and digits once or twice per piece
(Stormlight4, Retail2010, 128kbps), so they are kept, as are numeric
ids, words, file names and non ASCII names. The segment after /details/ or
/getnzb/ is a newznab release id and is kept. Info hashes and UUIDs in a
download URL or GUID are redacted like any other token, which only costs
readability: grabs take the real URL from the server's record. A redacted run
becomes REDACTED- and 12 hex characters, a keyed hash that changes on every
restart, so two releases stay apart without revealing the value. Not covered:
keys shorter than these lengths, keys of only letters or only digits, a short
token with few digits (a random 20 character base62 key reaches 4
alternations about 77% of the time, a 32 character one about 96%), and URL
fragments.
infoUrl is a link people click, and detail pages are often addressed by a
hex id (an MD5, an info hash), so it keeps its path and only loses credential
parameters and user:pass@. When infoUrl comes from the item's <link>
(which torznab feeds also use as a download link, with or without an
enclosure) or equals the download URL or the GUID, it is redacted like them.
History event data and the log export get the same treatment as nzbUrl,
and the log export also decodes the tracker URLs inside a magnet before
redacting them.
A grab of a GUID a recent search returned uses the server's own record of the
raw URL, so post guid back exactly as the search returned it. The record
lives in memory for 24 hours; after a restart or expiry a session grab answers
400 "this search result has expired, search again". An API key caller may
instead post a GUID the server has not seen along with its own raw nzbUrl,
which is grabbed as posted (with the indexer apikey added when the host
matches a configured indexer). Only the indexer apikey is put back: a
jackett_apikey, a passkey parameter, a passkey in the path or user:pass@
credentials that a response redacted cannot be recovered, so such a caller
must post the raw URL it got from the indexer, not one copied from a Bindery
response.
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 givesclient_path: warn. A Deluge category is sent lowercased, because the Label plugin stores labels in lowercase and only accepts that form when labelling a torrent, soBooksuses thebookslabel's move completed path. - For Deluge,
categorycompares the configured categories, lowercased, with the labels the Label plugin lists, and says when it lowercases one. When the plugin is off it cannot list labels andcategoryisunknown. - 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) it runs its download folder through the same remaps in reverse, the client's own first andBINDERY_DOWNLOAD_PATH_REMAPas the fallback, soclientPathis the folder the client is actually given. A client remap rule that covers the folder wins even when it maps the folder to itself (/downloads:/downloads), which is how a client opts out of a global remap meant for others. When the global remap produced the sent folder but the client's own default save folder is an absolute path on Bindery's own folder (that folder, inside it or above it) rather than on the sent one,client_pathiswarnand the fix names that identity remap. A relative default such as rTorrent's stock./is not checked.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, too_small |
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.
rootFormat is the format the row's library root holds, ebook or
audiobook, and is present only when BINDERY_AUDIOBOOK_DIR is a separate
folder from the library; with one combined root it is omitted. A row whose
format differs from it, such as an ebook under the audiobooks root, is in
the other format's folder (#2944). A too_small row is an ebook format file
under 4 KiB, too small to be a book: it is its own row and carries no
candidates.
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, a format that is not the files' format, or an ebook file under 4 KiB, too small to be a book (Manual Import is the override for such a file) |
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.
POST /api/v1/calibre/test probe calibredb or the Bindery Bridge plugin (admin)
POST /api/v1/calibre/import start a library import from Calibre (admin)
GET /api/v1/calibre/import/status library import progress (admin)
POST /api/v1/calibre/sync Push all: queue every eligible book for delivery (admin, plugin mode)
GET /api/v1/calibre/sync/status progress of the last Push all, read from the delivery queue (admin)
GET /api/v1/calibre/deliveries/summary queue counts, last delivery, whether Calibre was reachable (admin)
GET /api/v1/calibre/deliveries queue rows with book title and author (admin; `?state=&limit=&offset=`)
POST /api/v1/calibre/deliveries/retry put failed or skipped rows back in the queue (admin)
DELETE /api/v1/calibre/deliveries?state=pending drop every waiting row (admin)
POST /api/v1/calibre/deliveries/reset forget every delivery, for a new Calibre library (admin; needs {"confirm": true})
GET /api/v1/book/{id}/calibre one book's delivery state (anyone who can see the book)
Every ebook Bindery sends to Calibre goes through a delivery queue (#2832),
one row per ebook file. A row is pending (waiting, or retrying after an
error), delivered, failed (gave up; only a retry puts it back) or
skipped (the book or file went away before it could be sent). Imports queue
their file, and a worker delivers every minute and straight after an import,
so a book imported while Calibre is closed goes out once Calibre is back. A
Calibre that cannot be reached is not counted as an attempt.
Push all (POST /calibre/sync) queues each imported, monitored book's
ebook file unless one of the book's ebook files is already in the queue, in
any state. A delivered book is never queued again and a failed one is not
rearmed; use retry for that. The response is 202 with the same progress
shape GET /calibre/sync/status returns: running stays true while any book
the run queued is still waiting, and the counts come from the queue
(pushed is newly added by Calibre, alreadyInCalibre includes books
delivered before the run, failed holds failures with their error, skipped
holds books the run left out and why). It still needs plugin mode: calibredb
has no "already in the library" answer, so a bulk run there would turn every
book the library already holds into a failure. A second Push all while the
first is still queueing is a 409.
GET /calibre/deliveries/summary:
{
"pending": 3, "delivered": 412, "failed": 1, "skipped": 0,
"lastDeliveredAt": "2026-09-27T11:02:13Z",
"mode": "plugin",
"target": {
"lastPassAt": "2026-09-27T12:01:00Z",
"checkedAt": "2026-09-27T12:01:00Z",
"reachable": false,
"lastError": "Get \"http://calibre:8099/v1/health\": dial tcp: connection refused"
},
"transport": "push",
"pull": {}
}target is what the worker last learned. It only refreshes while something is
waiting, so with an empty queue checkedAt can be old.
transport is calibre.plugin_transport (push or pull). In pull the
worker never contacts Calibre, so target stays empty and pull says when
the plugin last reached the bridge routes: lastSeen, pluginVersion,
capabilities, remoteAddr, and library, the Calibre library it last
acknowledged a delivery into. It is kept in memory, so it is empty after a
restart until the plugin checks in again.
GET /calibre/deliveries returns {items, total}. state is pending,
delivered, failed, skipped or empty for all; limit defaults to 50 and
caps at 500. Each item is the queue row (filePath, format, state,
outcome, attempts, lastError, lastErrorCode, calibreId, updatedAt,
deliveredAt) plus bookTitle and authorName.
POST /calibre/deliveries/retry takes {"state": "failed"}, "skipped" or
"" for both, resets their attempts and returns {"requeued": N}. DELETE /calibre/deliveries only accepts state=pending and returns {"cleared": N};
the record of what was delivered is never cleared this way, because it is what
stops a book being sent twice. POST /calibre/deliveries/reset deletes every
row and returns {"removed": N}. It is for pointing Bindery at a different
Calibre library, where the old records would claim books are in a library
they never reached. Nothing is sent to the new library until Push all or an
import queues it.
GET /book/{id}/calibre answers {"state": ...} with off when the
integration is off, none when the book was never queued, or the state of
the row that speaks for the book (delivered first, then pending, failed,
skipped). Admins also get outcome, lastError, lastErrorCode,
attempts, calibreId and deliveredAt; other users get the state only,
since the error text can name server paths. A book the caller cannot see is a
404, as with GET /book/{id}.
With calibre.mode set to plugin and calibre.plugin_transport set to
pull (#2833), the Calibre Bridge plugin (0.8.0 or later) connects out to
Bindery instead of Bindery connecting to it. The plugin lists the due
deliveries, downloads each file, adds it to Calibre and acknowledges it. The
push worker stands down in pull, so a book is never sent both ways.
GET /bridge/v1/hello Bindery version, protocol, page size, transport
GET /bridge/v1/deliveries due deliveries (`?limit=&cursor=`)
GET /bridge/v1/deliveries/{id}/file the book file
GET /bridge/v1/deliveries/{id}/cover the cover image
POST /bridge/v1/deliveries/{id}/ack the plugin added it
POST /bridge/v1/deliveries/{id}/nack the plugin could not add it
These routes sit at the root like /opds, outside /api/v1, and under
BINDERY_URL_BASE when one is set.
Authentication. Every route needs Authorization: Bearer <key>, where the
key is calibre.plugin_api_key, the same key push mode sends to the plugin.
It is required in every auth mode, including Disabled and Local only. The
global API key, X-Api-Key, ?apikey= and session cookies are not accepted
here, and the plugin key is accepted nowhere else. A stored key that is empty
or shorter than 16 characters refuses every request. Failed attempts are
counted per client address on a limiter of their own, separate from the
login limiter, so a plugin with a stale key cannot lock the admin out of the
web UI; past the limit the answer is 429 with Retry-After.
The plugin sends X-Bridge-Version: <plugin version> and
X-Bridge-Capabilities: <comma separated> (for example
book_metadata,cover,add_format) on every request. Bindery records the last
contact for the settings page.
Errors are JSON {"error": "...", "code": "..."}:
| Status | code | When |
|---|---|---|
| 400 | invalid_request |
bad id, limit, cursor or body |
| 401 | unauthorized |
missing or wrong key, or no usable key stored |
| 403 | path_forbidden |
the delivery's file is outside the library roots, or not a regular file |
| 404 | not_found |
no such pending delivery, the file is gone, or no cover |
| 409 | not_in_pull_mode |
a delivery route while mode is not plugin or transport is not pull |
| 409 | not_pending |
ack or nack of a row that already failed, was skipped, or was delivered to another Calibre id |
| 429 | rate_limited |
too many failed attempts from this address |
GET /bridge/v1/hello answers in push too, so the plugin can report that
Bindery is not in pull mode:
{"binderyVersion": "v1.39.0", "protocol": 1, "maxBatch": 20, "transport": "pull"}GET /bridge/v1/deliveries returns the pending rows that are due, a page at
a time. limit defaults to 20 and caps at 50; cursor is opaque, and an
empty nextCursor means there is nothing further. pending counts every due
row, not just this page.
{
"deliveries": [
{"id": 812, "bookId": 97, "format": "epub", "sizeBytes": 482113,
"action": "add", "hasCover": true,
"metadata": {"title": "Emma", "authors": ["Jane Austen"],
"identifiers": {"bindery": "97", "isbn": "9780141439587"}}}
],
"nextCursor": "97",
"pending": 3
}metadata is the object push mode sends in POST /v1/books, without
coverPath. Pages hold whole books and list each book's preferred format
first (epub, kepub, azw3, mobi, pdf, then the rest), like the push worker.
action is add for the first file of a book, and add_format for a file of
a book that already has a delivered file. A book's other files are held back
until its first is acknowledged, then listed as add_format. A plugin that
does not advertise add_format is never offered one: those rows are skipped
with the same reason push uses, and put back in the queue the next time the
plugin lists with add_format advertised.
GET /bridge/v1/deliveries/{id}/file streams the file recorded on the row
with Content-Type: application/octet-stream, Content-Length and
Content-Disposition: attachment; filename="book.<ext>". Range requests
work. The path must be inside a library root, the same allow list as the
download route, and must be a regular file. A file that has gone is a 404
and the row is skipped. Only pending rows are served.
GET /bridge/v1/deliveries/{id}/cover returns the cover image with its
content type, or 404.
POST /bridge/v1/deliveries/{id}/ack takes
{"calibreId": 1234, "outcome": "added" | "already" | "format_added", "coverApplied": true, "library": "C:\\Users\\me\\Calibre Library"} and
answers 204. The row is marked delivered against the reported library, and
books.calibre_id follows the push rule: it is filled only when the book has
none, did not come from a Calibre import, and either no library path is set
or the library is that one. Sending the same ack again is a 204.
POST /bridge/v1/deliveries/{id}/nack takes {"code": "calibre_busy", "error": "database is locked", "retryable": true} and answers 204. It
counts one attempt. retryable: false, or a bad_format or
path_forbidden code, fails the row for good; otherwise it backs off on the
push schedule (1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, 24 hours)
and gives up after 8 attempts.
The delivery queue is not per user: it is the install's one Calibre target, and every route here is as privileged as an admin reading the queue.
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, not even for an admin.
Every other key is returned to admins only, with a short allowlist of keys a
non admin screen reads (#2361). A non admin caller gets exactly these, and any
other key is left out of the list and answers 404 from a read, the same as an
unset key:
| Key | Read by |
|---|---|
recommendations.enabled |
Discover page |
metadata.primary_provider |
add to library dialog |
library.defaultRootFolderId |
add author dialog |
library.defaultAudiobookRootFolderId |
add author dialog |
default.media_type |
add author dialog |
author.default_monitor_mode |
add author dialog |
author.default_monitor_latest_count |
add author dialog |
Requests authenticated with the API key are treated as admin, so a script or a
third party client using the key still reads every non secret setting. A client
signed in as a user account that read other keys should switch to the API
key. adminOnly in the descriptors below says which side of the line a key is
on.
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 revoke this session server side and clear the cookie
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}/auto-approve per-account request auto-approval (admin), body {"enabled": true|false}
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.