ShelfClub for developers
ShelfClub has a read-only public API over its book catalog: search, a book with its tags, similar books, series, authors and the list of tags. It answers JSON over HTTPS. No key and no sign-in are needed.
Base URL
https://api.shelfclub.app/api/v1Every path on this page comes after it. Every route is a GET.
Before you build on it
No licence and no terms of use have been published for this API or its data yet. If you are building a product on it, write to [email protected] first.
Limits
Requests are counted per client address, and each route is counted on its own.
| Route | Requests a minute |
|---|---|
| GET /books/search | 20 |
| GET /books/resolve | 20 |
| GET /books/isbn/:isbn | 100 |
| GET /books/browse | 20 |
| GET /books/:id | 100 |
| GET /books/:id/tags | 100 |
| GET /books/:id/similar | 100 |
| GET /books/:id/series | 100 |
| GET /authors/:id | 100 |
| GET /authors/:id/books | 100 |
| GET /taxonomy | 100 |
| GET /tags/coverage | 100 |
Past a limit the API answers 429 Too Many Requests, with a Retry-After header.
A second base URL
api.shelfclub.app answers servers as well as browsers. The same API is also served at this base URL, outside Cloudflare, and it answers with what the API answers. Use it if you are ever answered with a challenge page instead of JSON.
https://mcp.shelfclub.app/v1curl "https://mcp.shelfclub.app/v1/books/search?q=dune"What is different there:
- It serves every route on this page except
GET /books/:id/series, at the same paths, and takesGETonly. - It allows 60 requests a minute per caller, over all routes together. The limits above apply as well.
- Every
200carries the headerX-ShelfClub-Tags: machine-generated, or voted for by at least three readers of the book, not checked against human labels, and any origin may read the answers (Access-Control-Allow-Origin: *). - An answer can be up to five minutes old.
- An
:idmust be a uuid: anything else is a404. - Of the API's errors, a
400, a404and a429are passed on as they are. Any other, the503ofGET /books/browseandGET /tags/coverageincluded, comes back as a502.
An API key replaces the limit of 60 requests a minute per caller, and the limits per route above, with one limit of its own. It is sent with each request in the header Authorization: Bearer <key>, and a key that is not valid is answered 401. Keys are given out by hand: to ask for one, write to [email protected].
Where the tags come from
The tags on a book are machine-generated, with one exception: a mood, a vibe or a pace is also put on a book when at least three readers of the book vote for it. tagging.readers on a book names the ones its readers voted for. How ShelfClub tags books says how the tags are made, what a reader's vote does and how the tags are being measured.
We ask one thing, as a request and not as a condition: if you show a book's tags, link to the book's page on shelfclub.app.
https://shelfclub.app/book/<workId>A book, as the API returns it
ShelfClub keeps one record for a book, which the API calls a work, and one for each edition of it. An id on this page is a work id unless its line says otherwise.
In GET /books/isbn/:isbn and GET /authors/:id/books, a book that has no work record is described from one of its editions: its id is then that edition's id, and its genres list is empty.
A book has these fields wherever one is returned.
idstring (uuid)- The work's id: what every
/books/:idroute takes. titlestring- Title.
authorslist of strings- Author names, lead author first.
descriptionstring or null- Description.
coverUrlstring or null- Cover image URL.
categorieslist of strings, or null- Subject strings as the source catalogs gave them (free text).
genresis the normalised list. averageRatingnumber or null- Average rating out of 5, or null when there is none.
ratingCountinteger or null- How many ratings the average is over.
readCountinteger or null- The popularity count the catalog keeps for the work.
moodslist of strings- Moods. The terms are listed by
GET /taxonomy. vibeslist of strings- Vibes. The terms are listed by
GET /taxonomy. pacestring or null- Pace: one term, or null. The terms are listed by
GET /taxonomy. genreslist of strings- Genres. The terms are listed by
GET /taxonomy. contentWarningslist of strings- Content warnings as
type:severity, for exampledeath:moderate. An entry with no severity reads as moderate. An empty list is an answer only whentagging.contentWarningsischecked(seetaggingbelow). seriesstring or null- Series name as the source catalogs gave it.
seriesPositionnumber or null- Position in that series.
GET /books/:id adds four.
authorLinkslist of objects- The authors that have an author record, each as
id(whatGET /authors/:idtakes) andname. Can be shorter thanauthors, or empty. editionobject or null- The work's best-matching edition:
isbn,pageCount,publisher,publishedDate(year first; a year alone when that is all the source gave) andlanguage. Each can be null. The whole object is null when the work has no edition. taggingobject- What the tags of this work rest on: whether a model produced them, when, which of them its readers voted for, what a person corrected after a reader's report, and whether an empty warning list is an answer.
idsobject- The identifiers the work goes by. Its editions' ISBNs are not listed;
GET /books/resolvefinds the work from any of them. coverSourceobject or null- Where the image at
coverUrlcomes from, asnameandurl: Open Library, Google Books, Apple Books or The New York Times. Null when there is no cover, or it is not from one of them.
The fields of tagging
modelTaggedboolean- True when the tagging model has tagged this work. When false, any moods, vibes, pace and warnings on it come from keyword matching on its title, description and subjects, from a page-count rule (pace), and from readers' votes.
taggedAtstring or null- When the model last tagged the work (ISO 8601, UTC). Null when it never has.
taggerVersioninteger- The tagger version stamped on the work; 0 when none is. Versions differ in prompt and rules, so tags from different versions are not comparable one to one.
contentWarningscheckedorunchecked- How to read the work's
contentWarningslist, above all an empty one.checked: the model tagged the book, at a version that records what it gave (3 or later), so an empty list means it named no warning.unchecked: there is no such pass (the book was never tagged, was tagged by an older version, or the tagger gave up on it), so an empty list says nothing about the book.checkedsays what was done, not how right the result is: the model reads the book's description and, for some books, its English Wikipedia article, not the book. contentWarningSourcesobject or null- Who gave each warning, keyed by warning type.
llm: the model, from the book's description and, for some books, its English Wikipedia article.llm+twdb: the model, with the warnings reported for the book in the Trigger Warning Database in front of it.keyword: a keyword match.user: a person, who added it after a reader reported it as missing (it is incorrections). A type incontentWarningsthat is missing here was recorded before sources were kept. Null when no source is on record. attributionslist of objects- The outside sources to credit wherever this work's content warnings are shown, each as
nameandurl. The Trigger Warning Database is listed whencontentWarningSourceshas a type markedllm+twdb. Empty when no outside source is on record. readersobject- Which of the moods, vibes and pace on this work its readers voted for. A mood, a vibe or a pace is on a work because the catalog put it there (the model, a keyword match, or the page-count rule for pace) or because at least
minimumreaders of the book voted for it. This names the ones enough readers voted for, and whether the catalog gave each as well; any other mood, vibe or pace on the work is the catalog's alone. Genres and content warnings are not voted on. Readers are counted as of now: a tag that only readers put on the work stays on it, unnamed here, after one of them takes the book off their shelf, until the next vote on the book removes it. correctionslist of objects- What a person changed on this work's tags after a reader's report. A tag listed as
removedis in none of the work's lists and stays out of them. A content warning listed asaddedis incontentWarnings, withuseras its source. Each says when it was made (decidedAt). Empty when nothing was corrected, which is so for nearly every work.
The fields of tagging.readers
minimuminteger- How many readers of a book must vote for a mood, a vibe or a pace before it is put on the book. A tag the catalog has put there needs none.
moodslist of objects- The moods on the work that at least
minimumreaders of the book voted for, in the ordermoodshas them. Empty when there is none: every mood on the work is then the catalog's alone. vibeslist of objects- The vibes on the work that at least
minimumreaders of the book voted for, in the ordervibeshas them. Empty when there is none. paceobject or null- The work's pace, when at least
minimumreaders of the book voted for it. Null when the work has no pace, or the pace it has is the catalog's alone.
Each mood and vibe listed there, and the pace, has three fields.
tagstring- The term, as the work's
moods,vibesorpacehas it. readersinteger- How many readers of the book voted for it. A reader of the book is someone who has it on their reading or finished shelf; one person counts once, whichever of its editions they voted on.
alsoFromCatalogboolean- True when the catalog has given the book this tag as well, now or in an earlier pass: it stays on the book if the readers' votes go. False when their votes are the only thing that puts it there.
An entry of tagging.corrections
fieldmood,vibe,pace,genreorcontent_warning- Which kind of tag was corrected.
tagstring- The term. For a content warning it is the type alone, without a severity.
changeremovedoraddedremoved: a reader reported the tag as wrong, a person agreed, and it is kept off the work: neither the tagger nor readers' votes put it back.added: a reader reported a content warning as missing and a person added it.decidedAtstring or null- When the correction was made (ISO 8601, UTC): when a person accepted the report behind it. Null for a correction made before these times were recorded. It says when, never who.
The fields of ids
workIdstring (uuid)- The ShelfClub work id: the
idevery/booksroute takes and returns. openLibraryWorkIdstring or null- The Open Library work key, without the
/works/prefix. Null when the catalog holds none for this work.
Routes
Each route has its parameters, a request to try and a shortened answer. In the answers, ... stands for what is left out, and the values are made up: only the names and the shapes are the API's. The ids are made up too. Take a real one from a search.
Find a book
GET /books/search
Search the catalog by title or author.
Books with no cover image are left out, and so are books that carry an adult_content or graphic_sex warning.
fuzzy is true when no book contained the words of q and data holds the books whose title or author is closest to it instead, which is how a misspelled title is still found. Such an answer is a single page, and its total is the number of books in it.
qin the query- What to look for: a title or an author's name, in any script. A
qwith no letter or digit in it (emoji or punctuation alone) finds no book. pagein the query- The page, from 1. Default 1.
limitin the query- Books on a page. Default 20, at most 100.
Request
curl "https://api.shelfclub.app/api/v1/books/search?q=dune"Answer
{
"data": [
{
"id": "660e8400-e29b-41d4-a716-4466554400a1",
"title": "Dune",
"authors": ["Frank Herbert"],
...
}
],
"pagination": { "page": 1, "limit": 20, "total": 42, "totalPages": 3 },
"fuzzy": false
}GET /books/resolve
Find a book by an identifier you already hold.
Give exactly one of the four parameters. The answer names the ShelfClub work, whose id GET /books/:id and its sub-routes take. matchedBy says which parameter found it. In ids, the last three are those of the edition that matched: each is null when the match was on the work itself or that edition has none.
The route only reads. An identifier the catalog does not have is a 404, and nothing is created. The answer is a 400 when none or several identifiers are given, when one is longer than 64 characters, or when an Open Library key is of the other kind (an edition key where a work key goes, or the reverse).
isbnin the query- An ISBN-10 or ISBN-13, with or without hyphens. Either length of the same ISBN finds the same book.
openLibraryIdin the query- An Open Library edition key such as
OL7353617M, bare or as/books/OL7353617M. A work key goes inopenLibraryWorkId. openLibraryWorkIdin the query- An Open Library work key such as
OL893415W, bare or as/works/OL893415W. An edition key goes inopenLibraryId. googleBooksIdin the query- A Google Books volume id. Case matters.
Request
curl "https://api.shelfclub.app/api/v1/books/resolve?isbn=9780441013593"Answer
{
"workId": "660e8400-e29b-41d4-a716-4466554400a1",
"matchedBy": "isbn",
"url": "https://shelfclub.app/book/660e8400-e29b-41d4-a716-4466554400a1",
"ids": {
"workId": "660e8400-e29b-41d4-a716-4466554400a1",
"openLibraryWorkId": "OL893415W",
"isbn": "9780441013593",
"openLibraryId": "OL7353617M",
"googleBooksId": "B1hSG45JCX4C"
}
}GET /books/isbn/:isbn
A book and the edition that carries an ISBN.
work is a book with the fields listed above, without the four that GET /books/:id adds. edition is the edition with this ISBN. The answer is a 404 when no edition has it.
isbnin the path- An ISBN-10 or ISBN-13.
Request
curl "https://api.shelfclub.app/api/v1/books/isbn/9780441013593"Answer
{
"work": {
"id": "660e8400-e29b-41d4-a716-4466554400a1",
"title": "Dune",
"authors": ["Frank Herbert"],
...
},
"edition": {
"isbn": "9780441013593",
"pageCount": 528,
"publisher": "Ace",
"publishedDate": "1965-06-01",
"language": "en",
"coverUrl": "https://example.com/covers/dune-ace.jpg"
}
}GET /books/browse
Books that carry all of the given tags, most-read first.
Give at least one of moods, vibes, genres or pace. A book is returned only when it has every term given: moods=dark,emotional&genres=science-fiction&pace=fast is dark and emotional and science fiction and fast. Books that carry an adult_content or graphic_sex warning are left out.
A page is ordered by readCount, highest first, books with none last. Walk the pages by cursor: send nextCursor back as cursor, with the same tags, until it is null.
A book here has the fields listed above except contentWarnings, which this route does not return.
Most books carry no mood and no vibe (GET /tags/coverage has the numbers), so the answer is the books that are tagged this way, not every book the terms would fit.
The answer is a 400 when no tag is given, when a term is not in the vocabulary (the message names it), or when limit or cursor is malformed. It is a 503 when the request took longer than this route allows: try again shortly, or add a tag to narrow it.
moodsin the query- Moods the book must have, comma-separated. It must have every one.
vibesin the query- Vibes the book must have, comma-separated. It must have every one.
genresin the query- Genres the book must have, comma-separated. It must have every one.
pacein the query- The pace the book must have: one term.
limitin the query- Books on a page. Default 20, at most 50.
cursorin the query- The
nextCursorof the page before, unchanged, with the same tags. Leave it out for the first page.
Request
curl "https://api.shelfclub.app/api/v1/books/browse?moods=dark,emotional&genres=science-fiction&pace=fast"Answer
{
"data": [
{
"id": "660e8400-e29b-41d4-a716-4466554400a1",
"title": "Dune",
"authors": ["Frank Herbert"],
...
}
],
"nextCursor": "WzQxMiwiNjYwZTg0MDAtZTI5Yi00MWQ0LWE3MTYtNDQ2NjU1NDQwMGExIl0"
}The fields of the answer
datalist of books- The books of this page, most-read first. Empty when no book has every requested tag.
nextCursorstring or null- Send it back as
cursor, with the same tags, for the next page. Null on the last page. It marks a position and is not meant to be read or built.
One book
GET /books/:id
A book: its details, its tags and what the tags rest on.
The answer is always the work, and its id is the work id, whichever of the three kinds of id was sent. It is a 404 when no work goes by the id.
idin the path- A work id, the id of one of its editions, or the id of a work since merged into another (uuid).
Request
curl "https://api.shelfclub.app/api/v1/books/660e8400-e29b-41d4-a716-4466554400a1"Answer
{
"id": "660e8400-e29b-41d4-a716-4466554400a1",
"title": "Dune",
"authors": ["Frank Herbert"],
"description": "On the desert planet Arrakis, the heir of a noble house is drawn into a war over the most valuable substance in the universe.",
"coverUrl": "https://example.com/covers/dune.jpg",
"categories": ["Science fiction", "Fiction"],
"averageRating": 4.3,
"ratingCount": 1820,
"readCount": 412,
"moods": ["dark", "emotional"],
"vibes": ["page-turner"],
"pace": "fast",
"genres": ["science-fiction"],
"contentWarnings": ["death:moderate", "violence:mild"],
"series": "Dune",
"seriesPosition": 1,
"authorLinks": [
{ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "name": "Frank Herbert" }
],
"edition": {
"isbn": "9780441013593",
"pageCount": 528,
"publisher": "Ace",
"publishedDate": "1965-06-01",
"language": "en"
},
"tagging": {
"modelTagged": true,
"taggedAt": "2026-10-01T04:12:09.000Z",
"taggerVersion": 3,
"contentWarnings": "checked",
"contentWarningSources": { "death": "llm+twdb", "violence": "llm" },
"attributions": [
{
"name": "Trigger Warning Database",
"url": "https://triggerwarningdatabase.com"
}
],
"readers": {
"minimum": 3,
"moods": [
{ "tag": "dark", "readers": 4, "alsoFromCatalog": true }
],
"vibes": [],
"pace": { "tag": "fast", "readers": 3, "alsoFromCatalog": false }
},
"corrections": [
{
"field": "mood",
"tag": "funny",
"change": "removed",
"decidedAt": "2026-10-12T05:55:00.000Z"
}
]
},
"ids": {
"workId": "660e8400-e29b-41d4-a716-4466554400a1",
"openLibraryWorkId": "OL893415W"
},
"coverSource": null
}GET /books/:id/tags
A book's moods, vibes, pace and content warnings.
The tag fields of GET /books/:id on their own, with the same tagging object: tagging.readers names the moods, vibes and pace that readers of the book voted for. The answer is a 404 when no work goes by the id.
idin the path- A work id, or the id of a work since merged into another (uuid).
Request
curl "https://api.shelfclub.app/api/v1/books/660e8400-e29b-41d4-a716-4466554400a1/tags"Answer
{
"moods": ["dark", "emotional"],
"vibes": ["page-turner"],
"pace": "fast",
"contentWarnings": ["death:moderate", "violence:mild"],
"tagging": {
"modelTagged": true,
"taggedAt": "2026-10-01T04:12:09.000Z",
"taggerVersion": 3,
"contentWarnings": "checked",
"contentWarningSources": { "death": "llm+twdb", "violence": "llm" },
"attributions": [
{
"name": "Trigger Warning Database",
"url": "https://triggerwarningdatabase.com"
}
],
"readers": {
"minimum": 3,
"moods": [
{ "tag": "dark", "readers": 4, "alsoFromCatalog": true }
],
"vibes": [],
"pace": { "tag": "fast", "readers": 3, "alsoFromCatalog": false }
},
"corrections": [
{
"field": "mood",
"tag": "funny",
"change": "removed",
"decidedAt": "2026-10-12T05:55:00.000Z"
}
]
}
}GET /books/:id/similar
Books that share moods, vibes, pace or genres with this one, the most shared first.
The answer is a bare list of books, with no pagination. It is empty when the book has no tags to match on. Only the 10,000 most-read books in the catalog are suggested. Books with no cover image are left out, and so are books that carry an adult_content or graphic_sex warning. The answer is a 404 when no work goes by the id.
idin the path- A work id, or the id of a work since merged into another (uuid).
limitin the query- How many books. Default 10, at most 50.
Request
curl "https://api.shelfclub.app/api/v1/books/660e8400-e29b-41d4-a716-4466554400a1/similar?limit=5"Answer
[
{
"id": "0b9d4c1a-5e2f-4a7b-9c3d-2f1e6a8b7c90",
"title": "Dune Messiah",
"authors": ["Frank Herbert"],
...
}
]GET /books/:id/series
The series a book belongs to, in order.
data is the whole series, the book asked for included, and workId is that book. data is empty when ShelfClub has published no list for the series of this book, and always while enabled is false.
This route does not answer 404: an id it does not know gets the empty answer, with workId null.
idin the path- A work id or an edition id (uuid).
Request
curl "https://api.shelfclub.app/api/v1/books/660e8400-e29b-41d4-a716-4466554400a1/series"Answer
{
"enabled": true,
"seriesName": "Dune",
"workId": "660e8400-e29b-41d4-a716-4466554400a1",
"data": [
{
"id": "660e8400-e29b-41d4-a716-4466554400a1",
"title": "Dune",
"authors": ["Frank Herbert"],
"coverUrl": "https://example.com/covers/dune.jpg",
"seriesPosition": 1
},
{
"id": "0b9d4c1a-5e2f-4a7b-9c3d-2f1e6a8b7c90",
"title": "Dune Messiah",
"authors": ["Frank Herbert"],
"coverUrl": null,
"seriesPosition": 2
}
]
}Authors
GET /authors/:id
An author: name, biography, dates and photo.
bio, birthDate, deathDate and photoUrl can each be null. The two dates are text, not a fixed date format. The answer is a 404 when no author goes by the id.
idin the path- An author id (uuid), as
authorLinkson a book gives it.
Request
curl "https://api.shelfclub.app/api/v1/authors/7c9e6679-7425-40de-944b-e07fc1f90ae7"Answer
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "Frank Herbert",
"bio": "American science fiction author.",
"birthDate": "8 October 1920",
"deathDate": "11 February 1986",
"photoUrl": "https://example.com/authors/frank-herbert.jpg"
}GET /authors/:id/books
An author's books, the most recently published first.
The answer is a 404 when no author goes by the id.
idin the path- An author id (uuid).
pagein the query- The page, from 1. Default 1.
limitin the query- Books on a page. Default 20, at most 100.
Request
curl "https://api.shelfclub.app/api/v1/authors/7c9e6679-7425-40de-944b-e07fc1f90ae7/books"Answer
{
"data": [
{
"id": "660e8400-e29b-41d4-a716-4466554400a1",
"title": "Dune",
"authors": ["Frank Herbert"],
...
}
],
"pagination": { "page": 1, "limit": 20, "total": 24, "totalPages": 2 }
}The tags
GET /taxonomy
Every tag a book can carry, with its definition.
Every mood, pace, vibe, genre, content-warning type and severity, each with its definition where one is written. No definition is written for a genre yet, so there it is null.
A definition states what the term is meant to mean, and is the wording ShelfClub's human labellers work from. The model that tags books is prompted with wording of its own, so read a tag on a book as that model's use of the term, or as its readers' where tagging.readers on the book names it.
version is a digest of the terms and definitions: it changes whenever one of them is added, removed, moved or reworded. Compare it for equality. It has no order and is not a release number. The answer is sent with Cache-Control: public, max-age=3600.
It takes no parameters.
Request
curl "https://api.shelfclub.app/api/v1/taxonomy"Answer
{
"version": "3f9a1c0b7e42",
"moods": [
{
"term": "cozy",
"definition": "Low-jeopardy warmth: small stakes, a safe community, danger kept mostly off the page. A village mystery or a small-town romance qualifies; a tense thriller in a charming setting does not."
},
...
],
"paces": [...],
"vibes": [...],
"genres": [
{ "term": "science-fiction", "definition": null },
...
],
"contentWarningTypes": [...],
"severities": [...]
}GET /tags/coverage
How much of the catalog carries each kind of tag.
Counts over the whole catalog, recounted about once an hour. Most works carry no mood and no vibe: read a tag missing from a book as "not tagged", not as "does not apply".
The answer is a 503 when no count is stored yet. Try again in a few minutes.
It takes no parameters.
Request
curl "https://api.shelfclub.app/api/v1/tags/coverage"Answer
{
"computedAt": "2026-10-08T19:20:00.000Z",
"works": 1000,
"modelTagged": 150,
"contentWarningsChecked": 140,
"withMoods": 240,
"withVibes": 200,
"withPace": 900,
"withGenres": 460,
"withContentWarnings": 80,
"withReaderTags": 4
}The fields of the answer
computedAtstring- When these numbers were counted (ISO 8601, UTC). Between counts the answer does not move.
worksinteger- Works in the catalog. Every other number here is a part of this one.
modelTaggedinteger- Works the tagging model has tagged: those whose
tagging.modelTaggedis true. contentWarningsCheckedinteger- Works whose
tagging.contentWarningsischecked, the only ones on which an emptycontentWarningslist is an answer. On every other work an empty list says nothing about the book. withMoodsinteger- Works with at least one mood, whoever gave it (the model, a keyword match or readers).
withVibesinteger- Works with at least one vibe.
withPaceinteger- Works with a pace. A pace can come from a page-count rule as well as from the model, which is why this is far above
modelTagged. withGenresinteger- Works with at least one genre.
withContentWarningsinteger- Works with at least one content warning listed. A work missing from this count has none listed, which is not the same as having none: see
contentWarningsChecked. withReaderTagsinteger- Works with at least one mood, vibe or pace that enough readers of the book voted for: those whose
tagging.readersnames something. On every other work the moods, vibes and pace are the catalog's alone.
For AI assistants
There is also an MCP server, for AI assistants that connect to tools. It speaks Streamable HTTP and needs no sign-in.
https://mcp.shelfclub.app/mcpIt has 6 tools, all read-only:
search_booksfind_books_by_moodget_bookget_similar_booksget_trending_booksget_author