Skip to content
Developers

API reference

The read-only JSON API over the AudioSilo community audiobook metadata database.

Base URL
https://meta.audiosilo.app
Auth
None
CORS
Open to any origin
Writes
None - read-only

A read-only API over a compiled snapshot of the community metadata database at https://github.com/kodestar/audiosilo-meta.

Everything readable is public: no authentication, no API key and no rate-limit token, and no endpoint writes anything. (A production deployment may additionally register a release-notification hook at /hooks/github/release; that one route is HMAC-authenticated and is not part of the read surface.)

The API surface - /api/v1/* and /abs/search - carries permissive CORS headers (Access-Control-Allow-Origin: *), so a browser may call it directly. /healthz and the hook do not.

The server holds one compiled artifact at a time and swaps in a newer one without a restart, so a response is always a consistent view of a single release. While no artifact has loaded yet (a cold boot still fetching the newest release) every data route answers 503 with a Retry-After header; /api/v1/openapi.json and the static site are served regardless.

Some fields on GET /works/{id} depend on the loaded artifact's internal schema version - characters and recaps need version 2, the whole-book recap summary version 3, genres version 4. A server briefly serving an older artifact omits them rather than failing, so treat every one of them as optional.

Slugs are stable, and a slug a data-quality repair RETIRES keeps resolving: every route that takes an id answers 301 with a Location at the same route under the slug that replaced it, plus a body naming that slug so a stored id can be healed. The redirect is bounded by Cache-Control rather than permanent, because reversing a merge withdraws it. Redirects need artifact version 5; below it a retired slug is a 404.

Licensing: the factual core (works, recordings, people, series) is dedicated to the public domain under CC0-1.0 and the community layer (characters, recaps) is CC BY-SA 3.0 - see LICENSING.md in the repository for what attribution the share-alike layer requires.

Response shapes below are JSON skeletons whose values are types, not data:

"key"
always present
"key?"
omitted when empty or unknown
| null
the key is present, the value may be null
<A | B>
one of those shapes - each is documented under the endpoint that returns it

Works

Work documents: the abstract book, its recordings, and the community expressive layer.

GET /api/v1/works/latest

Recently added works

Work cards ordered by when they entered the database, newest first, with at most two works from any one series so a bulk import cannot fill the whole page with one series' volumes. Works with no recorded date sort last.

Parameters

Name In Type Notes
limit query integer default 12, 1-50 How many works to return. Capped at 50; a non-numeric or non-positive value falls back to the default (12) rather than being clamped to 1.

Responses

200 The newest work cards.
{
  "works": [
    {
      "id": string,
      "title": string,
      "authors": [
        {
          "id": string,
          "name": string
        }
      ],
      "series": {
        "id": string,
        "name": string,
        "position": string
      } | null,
      "cover_url": string | null,
      "added_at": string | null
    }
  ]
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}
GET /api/v1/works/{id}

One work in full

The whole work document: the abstract book, every recording of it (narrators, runtime, publisher, identifiers, chapter count), and the community expressive layer where it exists.

characters, recaps, recap_summary and genres depend on the loaded artifact's internal schema version and are omitted - never faked - when the artifact predates them.

A slug a merge has retired answers 301 at the work that replaced it, so a stored id keeps resolving.

Parameters

Name In Type Notes
id * path string The work's slug, e.g. project-hail-mary.

* required

Responses

200 The work document.
{
  "id": string,
  "title": string,
  "subtitle?": string,
  "authors": [
    {
      "id": string,
      "name": string
    }
  ],
  "language": string,
  "first_published?": string,
  "description?": string,
  "genres?": [
    string
  ],
  "series": [
    {
      "id": string,
      "name": string,
      "position": string
    }
  ],
  "xref?": {
    "wikidata?": string,
    "openlibrary?": string,
    "goodreads?": string,
    "isbn?": [
      string
    ]
  },
  "recordings": [
    {
      "id": string,
      "narrators": [
        {
          "id": string,
          "name": string
        }
      ],
      "abridged?": boolean,
      "runtime_min?": integer,
      "release_date?": string,
      "publisher?": string,
      "asin": [
        {
          "region": string,
          "asin": string
        }
      ],
      "isbn": [
        string
      ],
      "purchase_links?": [
        {
          "retailer": "audible" | "libro-fm",
          "id": string,
          "region?": string,
          "url": string,
          "availability": "unknown"
        }
      ],
      "cover_url?": string,
      "chapter_count": integer
    }
  ] | null,
  "characters?": [
    {
      "id": string,
      "name": string,
      "aliases?": [
        string
      ],
      "role?": string,
      "reveal": {
        "chapter": integer
      },
      "description?": string,
      "xref?": {
        "wikidata?": string,
        "goodreads?": string
      }
    }
  ],
  "recaps?": [
    {
      "through": {
        "chapter": integer
      },
      "scope?": "book" | "series",
      "text": string
    }
  ],
  "recap_summary?": {
    "in_short?": string,
    "ending?": string
  }
}
301 The slug was RETIRED - a data-quality repair merged its record into another one - and Location names the same route under the slug that replaced it. Following it is an ordinary GET. The body repeats the new slug, so a client that stored the old one can heal it instead of only learning that it is gone. It is answered only for a slug the database no longer holds, and never chains: one redirect always lands on a live record. Despite the status code it is NOT permanent - reversing a bad merge withdraws it - so it carries Cache-Control: public, max-age=3600 and must not be cached beyond that. An artifact older than internal schema version 5 carries no redirect table, so a retired slug is a 404 there.
{
  "redirect": string
}
404 No such record.
{
  "error": string
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}
GET /api/v1/works/{id}/recordings/{rid}/chapters

One recording's chapter list

The chapter marks of a single recording, in playback order, with millisecond offsets. An unknown work or recording is an empty list rather than a 404: absent chapters and an absent recording are the same answer to a client rendering a timeline. The one exception is a work slug a merge has RETIRED, which answers 301 at the same recording under the surviving slug.

Parameters

Name In Type Notes
id * path string The work's slug, e.g. project-hail-mary.
rid * path string The recording's slug, unique within its work, e.g. ray-porter-2021.

* required

Responses

200 The recording's chapters (empty when it has none, or does not exist).
{
  "chapters": [
    {
      "title": string,
      "start_ms": integer,
      "length_ms": integer
    }
  ]
}
301 The slug was RETIRED - a data-quality repair merged its record into another one - and Location names the same route under the slug that replaced it. Following it is an ordinary GET. The body repeats the new slug, so a client that stored the old one can heal it instead of only learning that it is gone. It is answered only for a slug the database no longer holds, and never chains: one redirect always lands on a live record. Despite the status code it is NOT permanent - reversing a bad merge withdraws it - so it carries Cache-Control: public, max-age=3600 and must not be cached beyond that. An artifact older than internal schema version 5 carries no redirect table, so a retired slug is a 404 there.
{
  "redirect": string
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}

People & Series

A person's credits and a series' member works, both windowed with unpaged totals.

GET /api/v1/people/{id}

One person and a page of their credits

A person with one page of each credit list - works authored and recordings narrated. Both lists take the same window.

The lists are paged by default because a person's credits are unbounded: a corporate credit of record ("Full Cast", "Audible Studios") narrates thousands of works. authored_total and narrated_total are the UNPAGED counts, so a client can always tell it is seeing a slice.

Parameters

Name In Type Notes
id * path string The person's slug, e.g. ray-porter.
limit query integer default 100, 1-500 Credits per list. Capped at 500; a non-numeric or non-positive value falls back to the default (100) rather than being clamped to 1.
offset query integer default 0, min 0 Rows to skip. A negative or non-numeric value is treated as 0.

* required

Responses

200 The person and one page of each credit list.
{
  "id": string,
  "name": string,
  "sort_name?": string,
  "authored": [
    {
      "id": string,
      "title": string,
      "authors": [
        {
          "id": string,
          "name": string
        }
      ],
      "series": {
        "id": string,
        "name": string,
        "position": string
      } | null,
      "cover_url": string | null,
      "added_at": string | null
    }
  ],
  "narrated": [
    {
      "work": {
        "id": string,
        "title": string,
        "authors": [
          {
            "id": string,
            "name": string
          }
        ],
        "series": {
          "id": string,
          "name": string,
          "position": string
        } | null,
        "cover_url": string | null,
        "added_at": string | null
      },
      "recording_id": string
    }
  ],
  "authored_total": integer,
  "narrated_total": integer,
  "limit": integer,
  "offset": integer
}
301 The slug was RETIRED - a data-quality repair merged its record into another one - and Location names the same route under the slug that replaced it. Following it is an ordinary GET. The body repeats the new slug, so a client that stored the old one can heal it instead of only learning that it is gone. It is answered only for a slug the database no longer holds, and never chains: one redirect always lands on a live record. Despite the status code it is NOT permanent - reversing a bad merge withdraws it - so it carries Cache-Control: public, max-age=3600 and must not be cached beyond that. An artifact older than internal schema version 5 carries no redirect table, so a retired slug is a 404 there.
{
  "redirect": string
}
404 No such record.
{
  "error": string
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}
GET /api/v1/series/{id}

One series and its member works

A series with its member works in position order. Positions are strings, so a novella ("2.5") and an omnibus range ("1-3.5") stay distinct from the numbered volumes.

Unlike a person's credits this returns the WHOLE series by default: series membership is bounded by what a series is, and consumers compose a complete series rail from it. Pass limit for a window; works_total is the unpaged membership either way.

Parameters

Name In Type Notes
id * path string The series' slug, e.g. the-stormlight-archive.
limit query integer 1-500 Member works to return. ABSENT means the whole series (there is no default window); a supplied value is capped at 500. A non-numeric or non-positive value falls back to that same default - the WHOLE series - rather than being clamped to 1.
offset query integer default 0, min 0 Rows to skip. A negative or non-numeric value is treated as 0.

* required

Responses

200 The series and its member works.
{
  "id": string,
  "name": string,
  "authors": [
    {
      "id": string,
      "name": string
    }
  ],
  "works": [
    {
      "position": string,
      "work": {
        "id": string,
        "title": string,
        "authors": [
          {
            "id": string,
            "name": string
          }
        ],
        "series": {
          "id": string,
          "name": string,
          "position": string
        } | null,
        "cover_url": string | null,
        "added_at": string | null
      }
    }
  ],
  "works_total": integer,
  "limit": integer,
  "offset": integer
}
301 The slug was RETIRED - a data-quality repair merged its record into another one - and Location names the same route under the slug that replaced it. Following it is an ordinary GET. The body repeats the new slug, so a client that stored the old one can heal it instead of only learning that it is gone. It is answered only for a slug the database no longer holds, and never chains: one redirect always lands on a live record. Despite the status code it is NOT permanent - reversing a bad merge withdraws it - so it carries Cache-Control: public, max-age=3600 and must not be cached beyond that. An artifact older than internal schema version 5 carries no redirect table, so a retired slug is a 404 there.
{
  "redirect": string
}
404 No such record.
{
  "error": string
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}

Lookup

Exact identifier resolution: which recording an ASIN or ISBN names.

GET /api/v1/lookup

Resolve an ASIN or ISBN to a recording

Exact identifier resolution: given an Audible ASIN or an ISBN, return the work card and the id of the recording that identifier names. Supply one of the two; if both are given, asin wins.

An ISBN is matched against recording ISBNs first and falls back to the work's print ISBNs, in which case the work's first recording is reported. Identifiers must be in their bare stored form (no hyphens).

Parameters

Name In Type Notes
asin query string An Audible ASIN, e.g. B08G9PRS1K. Takes precedence over isbn.
isbn query string A bare 10- or 13-digit ISBN, e.g. 9781427209269.

Responses

200 The work and recording the identifier names.
{
  "work": {
    "id": string,
    "title": string,
    "authors": [
      {
        "id": string,
        "name": string
      }
    ],
    "series": {
      "id": string,
      "name": string,
      "position": string
    } | null,
    "cover_url": string | null,
    "added_at": string | null
  },
  "recording_id": string
}
400 Neither asin nor isbn was supplied.
{
  "error": string
}
404 No such record.
{
  "error": string
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}

Coverage

What the community layer still needs: expressive-layer totals, a filtered per-work browser, and series with missing volumes.

GET /api/v1/coverage

Expressive-layer totals

How many works exist and how many carry each part of the community layer. A sidecar count is OMITTED (never reported as zero) when the loaded artifact predates its table, so a missing key means "unknowable here" while 0 means "genuinely none".

Responses

200 The top-line coverage totals.
{
  "totals": {
    "works": integer,
    "with_characters?": integer,
    "with_recaps?": integer,
    "with_recap_summary?": integer
  }
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}
GET /api/v1/coverage/works

Works by expressive-layer status

One filtered, searchable, paginated page of works for the coverage browser. Each row lists which parts of the community layer it still lacks.

available is false (with an empty page) when the requested filter's dimension cannot be evaluated against the loaded artifact.

Parameters

Name In Type Notes
filter query "missing" | "has_characters" | "has_recaps" | "has_recap_summary" default "missing" Which works to list. An empty value means missing.
q query string Narrow by full-text query over the work's title and subtitle and the names of its authors, narrators and series. Whole word or word prefix, the same matching as /api/v1/search.
limit query integer default 25, 1-100 Rows per page. Capped at 100; a non-numeric or non-positive value falls back to the default (25) rather than being clamped to 1.
offset query integer default 0, min 0 Rows to skip. A negative or non-numeric value is treated as 0.

Responses

200 One page of works for the filter.
{
  "works": [
    {
      "id": string,
      "title": string,
      "authors": [
        {
          "id": string,
          "name": string
        }
      ],
      "series?": {
        "id": string,
        "name": string,
        "position": string
      },
      "missing": [
        "characters" | "recaps" | "recap_summary"
      ]
    }
  ],
  "total": integer,
  "limit": integer,
  "offset": integer,
  "available": boolean
}
400 Unrecognized filter value.
{
  "error": string
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}
GET /api/v1/coverage/series-gaps

Series with missing volumes

One page of the series that have interior gaps: an integer position absent strictly between the lowest and highest present one. A decimal position ("2.5") fills no integer slot; an omnibus range ("1-3.5") fills every integer it spans.

Parameters

Name In Type Notes
q query string Case-insensitive substring of the series name.
limit query integer default 25, 1-100 Rows per page. Capped at 100; a non-numeric or non-positive value falls back to the default (25) rather than being clamped to 1.
offset query integer default 0, min 0 Rows to skip. A negative or non-numeric value is treated as 0.

Responses

200 One page of series with gaps.
{
  "gaps": [
    {
      "id": string,
      "name": string,
      "present": [
        string
      ],
      "missing_positions": [
        integer
      ]
    }
  ],
  "total": integer,
  "limit": integer,
  "offset": integer
}
500 A query against the loaded artifact failed. Not expected in normal operation - an artifact is checksum-verified before it is served - so read it as a bug or a damaged snapshot rather than a condition to code around. Retrying is safe: the API is read-only.
{
  "error": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}

Audiobookshelf provider

The Audiobookshelf custom metadata provider facade. It lives outside /api/v1 because Audiobookshelf appends /search to a configured base URL, and it speaks Audiobookshelf's BookMetadata shape rather than this API's.

Server

Readiness, catalogue totals, this document, and the release-notification hook.

GET /healthz

Readiness probe

Reports readiness, not liveness: a server that has not loaded an artifact yet answers 503, so a container health check or load balancer keeps it out of rotation until it can actually answer.

Responses

200 An artifact is loaded and the API is answering.
{
  "status": "ok",
  "built_at": string,
  "works": integer
}
503 No artifact has loaded yet. Retry-After reports the wait the poll loop is actually on.
{
  "status": "starting"
}
GET /api/v1/openapi.json

This document

The OpenAPI description of this API. It is static, so unlike every data route it answers even while the server is still fetching its first artifact.

The document is embedded in the binary and therefore constant for its lifetime: it is served with a strong ETag and a one-hour Cache-Control, and a conditional request revalidates instead of downloading it again.

Responses

200 The OpenAPI 3.1 document.
{ ... }
304 The If-None-Match request header named the current ETag, so the copy the client holds is still the document. No body.
GET /api/v1/stats

Catalogue totals

Top-line counts for the loaded artifact, plus the time it was built. Computed once when the artifact loads, so it is free to call.

Responses

200 The loaded artifact's totals.
{
  "works": integer,
  "recordings": integer,
  "people": integer,
  "series": integer,
  "total_runtime_min": integer,
  "total_chapters": integer,
  "built_at": string
}
503 No artifact has loaded yet - a cold boot still fetching the newest release. Temporary by construction; Retry-After reports the wait the server's refresh loop is on.
{
  "error": string
}
POST /hooks/github/release

Release notification (production only)

PRODUCTION ONLY, and registered at all only when the deployment sets METASERVE_WEBHOOK_SECRET. It exists so a freshly published data release is adopted in seconds instead of at the next hourly poll; the poll remains the fallback for a missed delivery.

The body is only a trigger - the server re-queries GitHub and goes through the same checksum-verified atomic swap as a poll, so a forged payload cannot install anything. The request must carry a valid X-Hub-Signature-256 HMAC over the raw body; anything else is rejected before the body is parsed. The response is always empty: 202 means "accepted, refreshing in the background", including for a delivery that is correctly signed but not a matching release event.

Error bodies on this route are plain text, not the JSON error envelope the API uses.

Parameters

Name In Type Notes
X-Hub-Signature-256 * header string sha256=<hex> HMAC-SHA256 of the raw request body, keyed with the deployment's webhook secret.
X-GitHub-Event header string GitHub's event name. Anything other than release is accepted and ignored.

* required

Request body

{
  "action?": string,
  "repository?": {
    "full_name?": string
  }
}

Responses

202 Accepted. A refresh runs in the background if the payload named a published release of this repository.
400 Unreadable body, or a signed payload that is not valid JSON. Plain text.
401 Missing or invalid X-Hub-Signature-256. Plain text.
413 Body over 1 MiB. Plain text.

Further reading

The full licensing policy is in LICENSING.md. The machine-readable contract for everything on this page is at /api/v1/openapi.json.