ShelfClub

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/v1

Every 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.

Requests a minute allowed on each route
RouteRequests a minute
GET /books/search20
GET /books/resolve20
GET /books/isbn/:isbn100
GET /books/browse20
GET /books/:id100
GET /books/:id/tags100
GET /books/:id/similar100
GET /books/:id/series100
GET /authors/:id100
GET /authors/:id/books100
GET /taxonomy100
GET /tags/coverage100

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/v1
curl "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 takes GET only.
  • It allows 60 requests a minute per caller, over all routes together. The limits above apply as well.
  • Every 200 carries the header X-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 :id must be a uuid: anything else is a 404.
  • Of the API's errors, a 400, a 404 and a 429 are passed on as they are. Any other, the 503 of GET /books/browse and GET /tags/coverage included, comes back as a 502.

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.

id string (uuid)
The work's id: what every /books/:id route takes.
title string
Title.
authors list of strings
Author names, lead author first.
description string or null
Description.
coverUrl string or null
Cover image URL.
categories list of strings, or null
Subject strings as the source catalogs gave them (free text). genres is the normalised list.
averageRating number or null
Average rating out of 5, or null when there is none.
ratingCount integer or null
How many ratings the average is over.
readCount integer or null
The popularity count the catalog keeps for the work.
moods list of strings
Moods. The terms are listed by GET /taxonomy.
vibes list of strings
Vibes. The terms are listed by GET /taxonomy.
pace string or null
Pace: one term, or null. The terms are listed by GET /taxonomy.
genres list of strings
Genres. The terms are listed by GET /taxonomy.
contentWarnings list of strings
Content warnings as type:severity, for example death:moderate. An entry with no severity reads as moderate. An empty list is an answer only when tagging.contentWarnings is checked (see tagging below).
series string or null
Series name as the source catalogs gave it.
seriesPosition number or null
Position in that series.

GET /books/:id adds four.

authorLinks list of objects
The authors that have an author record, each as id (what GET /authors/:id takes) and name. Can be shorter than authors, or empty.
edition object or null
The work's best-matching edition: isbn, pageCount, publisher, publishedDate (year first; a year alone when that is all the source gave) and language. Each can be null. The whole object is null when the work has no edition.
tagging object
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.
ids object
The identifiers the work goes by. Its editions' ISBNs are not listed; GET /books/resolve finds the work from any of them.
coverSource object or null
Where the image at coverUrl comes from, as name and url: 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

modelTagged boolean
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.
taggedAt string or null
When the model last tagged the work (ISO 8601, UTC). Null when it never has.
taggerVersion integer
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.
contentWarnings checked or unchecked
How to read the work's contentWarnings list, 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. checked says 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.
contentWarningSources object 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 in corrections). A type in contentWarnings that is missing here was recorded before sources were kept. Null when no source is on record.
attributions list of objects
The outside sources to credit wherever this work's content warnings are shown, each as name and url. The Trigger Warning Database is listed when contentWarningSources has a type marked llm+twdb. Empty when no outside source is on record.
readers object
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 minimum readers 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.
corrections list of objects
What a person changed on this work's tags after a reader's report. A tag listed as removed is in none of the work's lists and stays out of them. A content warning listed as added is in contentWarnings, with user as 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

minimum integer
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.
moods list of objects
The moods on the work that at least minimum readers of the book voted for, in the order moods has them. Empty when there is none: every mood on the work is then the catalog's alone.
vibes list of objects
The vibes on the work that at least minimum readers of the book voted for, in the order vibes has them. Empty when there is none.
pace object or null
The work's pace, when at least minimum readers 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.

tag string
The term, as the work's moods, vibes or pace has it.
readers integer
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.
alsoFromCatalog boolean
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

field mood, vibe, pace, genre or content_warning
Which kind of tag was corrected.
tag string
The term. For a content warning it is the type alone, without a severity.
change removed or added
removed: 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.
decidedAt string 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

workId string (uuid)
The ShelfClub work id: the id every /books route takes and returns.
openLibraryWorkId string 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.

q in the query
What to look for: a title or an author's name, in any script. A q with no letter or digit in it (emoji or punctuation alone) finds no book.
page in the query
The page, from 1. Default 1.
limit in 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).

isbn in the query
An ISBN-10 or ISBN-13, with or without hyphens. Either length of the same ISBN finds the same book.
openLibraryId in the query
An Open Library edition key such as OL7353617M, bare or as /books/OL7353617M. A work key goes in openLibraryWorkId.
openLibraryWorkId in the query
An Open Library work key such as OL893415W, bare or as /works/OL893415W. An edition key goes in openLibraryId.
googleBooksId in 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.

isbn in 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.

moods in the query
Moods the book must have, comma-separated. It must have every one.
vibes in the query
Vibes the book must have, comma-separated. It must have every one.
genres in the query
Genres the book must have, comma-separated. It must have every one.
pace in the query
The pace the book must have: one term.
limit in the query
Books on a page. Default 20, at most 50.
cursor in the query
The nextCursor of 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

data list of books
The books of this page, most-read first. Empty when no book has every requested tag.
nextCursor string 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.

id in 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.

id in 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.

id in the path
A work id, or the id of a work since merged into another (uuid).
limit in 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.

id in 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.

id in the path
An author id (uuid), as authorLinks on 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.

id in the path
An author id (uuid).
page in the query
The page, from 1. Default 1.
limit in 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

computedAt string
When these numbers were counted (ISO 8601, UTC). Between counts the answer does not move.
works integer
Works in the catalog. Every other number here is a part of this one.
modelTagged integer
Works the tagging model has tagged: those whose tagging.modelTagged is true.
contentWarningsChecked integer
Works whose tagging.contentWarnings is checked, the only ones on which an empty contentWarnings list is an answer. On every other work an empty list says nothing about the book.
withMoods integer
Works with at least one mood, whoever gave it (the model, a keyword match or readers).
withVibes integer
Works with at least one vibe.
withPace integer
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.
withGenres integer
Works with at least one genre.
withContentWarnings integer
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.
withReaderTags integer
Works with at least one mood, vibe or pace that enough readers of the book voted for: those whose tagging.readers names 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/mcp

It has 6 tools, all read-only:

  • search_books
  • find_books_by_mood
  • get_book
  • get_similar_books
  • get_trending_books
  • get_author