# OBOB.dog API

Hack the OBOB dog via API. Build weird stuff that helps kids have fun with OBOB and makes them smile.

## Welcome to the OBOB.dog API

**Experimental.** This is new and highly experimental; shapes may change.

The API is a way for other programs and apps to reach parts of OBOB.dog from their own code and do interesting things with that data. We hope you'll give it a try and build something weird and off the wall that helps kids experience OBOB in fun new ways.

Today the API offers three main kinds of data:

- **Book data.** Everything about this year's books.
- **Daily question.** Grab a question and its answer to use in your own app.
- **Reader and team progress.** Stats, feeds and progress from a reader's journey through the books.

Ideas for things to build:

- A screen in the kitchen or the classroom that shows today's question and flips to the answer.
- A team bookshelf on a TV, with progress bars that move as kids log pages.
- A morning text or group-chat message with one question in it.
- A smart speaker that reads the question out loud at dinner.
- A coach's spreadsheet that fills itself in with the team's progress.

We want to hear about what you're building. Email us at <hello@obob.dog>.

## The basics

This is the Markdown twin of <https://obob.dog/developers>, written to be handed to an AI assistant or a coding agent in one go: paste it, or point them at <https://obob.dog/developers.md>. The site's <https://obob.dog/llms.txt> lists it. The machine-readable description is <https://obob.dog/api/v1/openapi.json> (OpenAPI 3.1).

- Base URL: `https://obob.dog/api/v1`
- Auth: `Authorization: Bearer obob_…` on every request. API keys are made at <https://obob.dog/developers> by a signed-in parent of the Dog Eared Book Club, after agreeing to the [developer terms](https://obob.dog/developers/terms).
- Read-only: every endpoint is `GET`. Each also answers `OPTIONS` for browser preflight; cross-origin requests are allowed.
- Every response body carries `"apiVersion": "v1"` (also the `X-API-Version` header). Errors look like `{ "apiVersion": "v1", "error": { "code": "…", "message": "…" } }`.
- Reader and team codes go in the `X-Reader-Code` / `X-Team-Code` headers, never in a URL. The daily question is the only question endpoint for now; there is no list or search yet. A `304` from `If-None-Match` is free. Credit `source.name` next to any question you show.

## Examples to get you started

Set `OBOB_API_KEY` to an API key (signed in on the developers page, the samples there carry your API key already) and these run as they are: today's question, then a team's progress as bars. `READ-SAMPLE` and `TEAM-ROSIE-DEMO` are stand-ins no reader or team has (signed in on the developers page, the samples carry a parent's real reader and team codes); put a reader's login code or a coach's team code in their place. Nothing to install for curl or JavaScript (Node 18+). Python wants `requests`.

### 1. Today's question for a reader

Swap the `X-Reader-Code` header for `X-Team-Code` to ask for a team, or drop it and send `?division=3-5` for whole books. The same call answers the same all day.

```sh
curl -s https://obob.dog/api/v1/questions/daily \
  -H "Authorization: Bearer $OBOB_API_KEY" \
  -H "X-Reader-Code: READ-SAMPLE" \
  | jq -r '.question // .message
      | if type == "object" then .prompt.full, "Answer: \(.answer) (\(.reference.label // "no page"))" else . end'
```

```js
// Node 18+ or any browser: fetch is built in
const res = await fetch("https://obob.dog/api/v1/questions/daily", {
  headers: {
    Authorization: `Bearer ${process.env.OBOB_API_KEY}`,
    "X-Reader-Code": "READ-SAMPLE",
  },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.json()).error.message}`);
const { question, message, hint } = await res.json();

if (!question) {
  console.log(message, "\n", hint);
} else {
  console.log(question.prompt.full);
  console.log("Answer:", question.answer, question.reference.label ? `(${question.reference.label})` : "");
  console.log("Question by", question.source.name);
}
```

```python
import os, requests  # pip install requests

res = requests.get(
    "https://obob.dog/api/v1/questions/daily",
    headers={
        "Authorization": f"Bearer {os.environ['OBOB_API_KEY']}",
        "X-Reader-Code": "READ-SAMPLE",
    },
)
res.raise_for_status()
data = res.json()

if data["question"] is None:
    print(data["message"], data["hint"])
else:
    q = data["question"]
    print(q["prompt"]["full"])
    print("Answer:", q["answer"], f"({q['reference']['label']})" if q["reference"]["label"] else "")
    print("Question by", q["source"]["name"])
```

### 2. A team's reading progress, as bars in the terminal

For one reader use `GET /api/v1/reader` with `X-Reader-Code`: the shelf is a flat list of `{ book, status, percent }` entries, so the loop is the same without the inner one.

```sh
curl -s https://obob.dog/api/v1/team \
  -H "Authorization: Bearer $OBOB_API_KEY" \
  -H "X-Team-Code: TEAM-ROSIE-DEMO" \
  | jq -r '.books[] | select(.readers | length > 0)
      | .title, (.readers[] | "  \(.displayName | .[:12])\t\(if .status == "finished" then "done" else "\(.percent // 0)%" end)")'
```

```js
const res = await fetch("https://obob.dog/api/v1/team", {
  headers: { Authorization: `Bearer ${process.env.OBOB_API_KEY}`, "X-Team-Code": "TEAM-ROSIE-DEMO" },
});
const { team, books } = await res.json();

console.log(`${team.name} · grades ${team.division}\n`);
for (const book of books) {
  if (book.readers.length === 0) continue; // nobody has picked it up yet
  console.log(book.title);
  for (const r of book.readers) {
    const pct = r.percent ?? 0;
    const bar = "█".repeat(Math.round(pct / 5)).padEnd(20, "░");
    const tail = r.status === "finished" ? "done" : `${pct}%`;
    console.log(`  ${r.displayName.padEnd(12)} ${bar} ${tail}`);
  }
}
```

```python
import os, requests

res = requests.get(
    "https://obob.dog/api/v1/team",
    headers={"Authorization": f"Bearer {os.environ['OBOB_API_KEY']}", "X-Team-Code": "TEAM-ROSIE-DEMO"},
)
res.raise_for_status()
data = res.json()

print(f"{data['team']['name']} · grades {data['team']['division']}\n")
for book in data["books"]:
    if not book["readers"]:
        continue  # nobody has picked it up yet
    print(book["title"])
    for r in book["readers"]:
        pct = r["percent"] or 0
        bar = ("█" * round(pct / 5)).ljust(20, "░")
        tail = "done" if r["status"] == "finished" else f"{pct}%"
        print(f"  {r['displayName']:<12} {bar} {tail}")
```

## How it works

Every request is identified by an API key, sent as a bearer token.

- **API keys.** A parent can hold up to 5 API keys. Each one has a name, a description of the app it is for and an optional URL, all editable later on the developers page and echoed back by `GET /me`. An API key is `obob_` plus 40 lowercase letters and digits, and stays visible on the developers page so it can be copied again. Revoking an API key stops it at once. When the developer terms change, every API key answers `403 terms_not_accepted` until the parent agrees to the new version on the developers page.
- **Rate limit.** 60 requests per API key every 10 minutes. `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix seconds) come back on every response; a `429` carries `Retry-After`.
- **ETags.** Every response has a strong `ETag`. Send it back as `If-None-Match` and a `304 Not Modified` comes back free: it is not counted against the limit. Responses are `Cache-Control: private, no-cache`, so a browser revalidates on its own. A widget on a timer therefore pays only when something changed.
- **Codes travel in headers, never in URLs.** A reader's login code goes in `X-Reader-Code`, a team code in `X-Team-Code`. A code in the query string gets `400 code_in_url`. Paths and query strings land in access logs, browser history and referrers; headers don't.
- **A login code is a secret.** It opens that reader's shelf. Treat it like a password.
- **Privacy.** The API returns display names only: never a kid's id, school, parent or email. Nothing a teammate wouldn't see on obob.dog.
- **One question a day, for now.** The daily question is the only question endpoint; there is no list, search or fetch-by-id yet. The daily pick is per book, so varying the parameters does not get around that.
- **Dates.** "Today" and "this week" are Oregon time. Timestamps are ISO 8601 in UTC.
- **An API key in a web page is a key anyone can read.** Call the API from a server when you can.

## Endpoints

| Method and path | Scope header | What it gives |
| --- | --- | --- |
| `GET /books` | | The season's books with everything we know about them |
| `GET /questions/daily` | `X-Reader-Code` or `X-Team-Code` or `?division=` | One practice question for today |
| `GET /reader` | `X-Reader-Code` | A reader's shelf, totals and stats |
| `GET /reader/activity` | `X-Reader-Code` | What that reader did lately |
| `GET /team` | `X-Team-Code` | A team's board per member and per book, with stats |
| `GET /team/activity` | `X-Team-Code` | What the team's readers did lately |
| `GET /me` | | This API key and its rate-limit window (never counted) |
| `GET /openapi.json` | | The API described in OpenAPI 3.1 (no API key needed) |

### The book reference

A question, a shelf entry, a board row and an activity event all carry the same short book object, enough to draw a tile without a second call to `/books`:

```json
{
  "bookKey": "frindle",
  "title": "Frindle",
  "author": "Andrew Clements",
  "authorPronunciation": null,
  "coverUrl": "https://obob.dog/covers/2026-2027/3-5/frindle.jpg",
  "coverUrls": { "sm": "https://obob.dog/_next/image?url=…&w=256&q=75", "md": "https://obob.dog/_next/image?url=…&w=640&q=75" },
  "pageCount": 105,
  "chapterCount": 15,
  "genre": "Realistic fiction"
}
```

`coverUrl` is the full-size file. `coverUrls.sm` (about 256px wide) and `md` (640px) are the resized WebP the site itself uses; better for tiles and small screens. `bookKey` is the stable handle for a book within a season and division.

### `GET /books`

Everything we know about a season's books: titles, authors and how to say their names, covers, page counts, descriptions, reading levels.

Query:

- `year`: a season, like `2026-2027`. Default: the current season.
- `division`: `3-5`, `6-8` or `9-12`. Leave it out for every division at once.

```json
{
  "apiVersion": "v1",
  "year": "2026-2027",
  "division": "3-5",
  "count": 16,
  "books": [
    {
      "bookKey": "frindle",
      "title": "Frindle",
      "author": "Andrew Clements",
      "authorPronunciation": null,
      "division": "3-5",
      "coverUrl": "https://obob.dog/covers/2026-2027/3-5/frindle.jpg",
      "coverUrls": { "sm": "…", "md": "…" },
      "isbn": "9780689818769",
      "publisher": "Atheneum",
      "publicationYear": 1996,
      "pageCount": 105,
      "chapterCount": 15,
      "genre": "Realistic fiction",
      "description": "Nick Allen has plenty of ideas…",
      "lexile": "830L",
      "arLevel": "5.4",
      "awards": null,
      "spanishTitle": null,
      "isGraphicNovel": false,
      "isStudentChoice": false
    }
  ]
}
```

### `GET /questions/daily`

One practice question for today. Every book has its question of the day (Oregon time), the same for everyone who asks, so a widget, a kitchen screen or a morning email can ask once and trust it. This is the only question endpoint for now; there is no way to list or page through questions yet.

Scope, pick one (in this order of precedence):

- `X-Reader-Code: BARK-7X2M3Q`: a reader's login code (the one on their bookmark). The question comes from the books they've started, no further than the page they've reached, and skips questions they've already answered in a battle on obob.dog.
- `X-Team-Code: TEAM-FETCH-7K2M`: a team code, from the coach. Books any member has started, as far as the furthest reader has got; questions any member has answered are skipped.
- `?division=3-5` (or `6-8`, `9-12`): whole books, no shelf. Add `year=2026-2027` for another season and `books=frindle,holes,wonder,hatchet` (book keys, comma-separated) to narrow it.

Options:

- `type`: `in-which-book`, `content` or `both` (default). In-which-book needs at least four books in the scope, like a real battle.

```json
{
  "apiVersion": "v1",
  "date": "2026-10-30",
  "scope": {
    "kind": "reader",
    "year": "2026-2027",
    "division": "3-5",
    "type": "both",
    "reader": { "displayName": "Rosie" },
    "books": [ { "bookKey": "frindle", "title": "Frindle", "author": "Andrew Clements", "…": "…" } ]
  },
  "poolSize": 212,
  "answered": 31,
  "question": {
    "id": "6f1c…",
    "type": "content",
    "text": "What does Nick name his pen?",
    "prompt": {
      "prefix": "In Frindle by Andrew Clements,",
      "text": "what does Nick name his pen?",
      "full": "In Frindle by Andrew Clements, what does Nick name his pen?",
      "plain": "In Frindle by Andrew Clements, what does Nick name his pen?"
    },
    "answer": "A frindle",
    "reference": { "page": 42, "pageLabel": null, "section": "body", "label": "p. 42" },
    "book": { "bookKey": "frindle", "title": "Frindle", "author": "Andrew Clements", "…": "…" },
    "source": { "slug": "obob-volunteers", "name": "OBOB Volunteers", "link": "https://…" }
  }
}
```

- `prompt.full` is the question as a moderator reads it; `prompt.plain` is the same line with straight quotes and plain dashes for e-ink screens and voice assistants. For an in-which-book question, `answer` is "Title by Author" and `scope.books` has the choices.
- `reference.label` is "p. 42", or the part of the book ("Author's note") when the answer isn't on a numbered page. `section` is one of `front_cover`, `front_matter`, `body`, `epilogue`, `back_matter`, `back_cover`.
- `question` is `null` with a `message` (why) and a `hint` (what would change it) when there is nothing to ask: a reader with no started books, every question in reach already answered, unknown book keys. Show them; they are written for the screen.
- `scope.team` (for a team) lists the members' display names.
- Show `source.name` near the question. Volunteers wrote these; the terms ask you to credit them.
- The question is fixed for the day, so poll with `If-None-Match`: the 304 is free and the ETag changes at midnight Oregon time, or when a reader's pages or answers move the pick.

### `GET /reader`

A reader's shelf and how the season is going: each book with its status and how far they are, totals, and stats for a streak counter or a weekly tile. Exactly what the reader sees on their own dashboard and nothing more. Header: `X-Reader-Code`.

```json
{
  "apiVersion": "v1",
  "reader": { "displayName": "Rosie", "division": "3-5", "year": "2026-2027" },
  "totals": { "books": 5, "reading": 2, "toRead": 1, "finished": 2 },
  "shelf": [
    {
      "book": { "bookKey": "frindle", "title": "Frindle", "…": "…" },
      "status": "reading",
      "format": "print",
      "currentPage": 42,
      "currentChapter": null,
      "percent": 40,
      "readingNumber": 1,
      "timesFinished": 0,
      "startedAt": "2026-10-12T02:14:09.000Z",
      "finishedAt": null,
      "updatedAt": "2026-10-29T01:30:00.000Z"
    }
  ],
  "stats": {
    "streakDays": 4,
    "lastActiveAt": "2026-10-30T01:30:00.000Z",
    "reading": { "savesThisWeek": 5, "savesThisSeason": 31, "activeDays": 18 },
    "battles": {
      "answeredThisWeek": 16,
      "answeredThisSeason": 120,
      "correctThisSeason": 84,
      "percentRightThisSeason": 70,
      "battlesThisSeason": 15
    }
  }
}
```

- `status` is `reading`, `to-read` or `finished`; the shelf lists books in that order, newest activity first within each. `format` is `print` or `audio`.
- `percent` comes from the page (or the chapter) against the book's length: 100 when finished, 0 for a book on the pile, `null` when there's no way to tell.
- A streak is days in a row, Oregon time, with a progress save or a battle answer; today counts once something happens. "This week" is today and the six days before. `percentRightThisSeason` is full-credit answers out of scored ones (skips left out), `null` before any.

### `GET /team`

The team board: who's on it and who's reading what, per member and per book, with each reader's stats. The same board teammates see on obob.dog, nothing else. Header: `X-Team-Code`.

```json
{
  "apiVersion": "v1",
  "team": { "name": "The Page Turners", "division": "3-5", "year": "2026-2027", "memberCount": 4, "lastActivityAt": "2026-10-30T01:30:00.000Z" },
  "members": [
    {
      "displayName": "Rosie",
      "reading": 2,
      "toRead": 1,
      "finished": 3,
      "books": [
        { "bookKey": "frindle", "status": "reading", "format": "print", "currentPage": 42, "currentChapter": null, "percent": 40, "timesFinished": 0 }
      ],
      "stats": { "streakDays": 4, "lastActiveAt": "2026-10-30T01:30:00.000Z", "reading": { "…": "…" }, "battles": { "…": "…" } },
      "lastActivityAt": "2026-10-30T01:30:00.000Z"
    }
  ],
  "books": [
    {
      "bookKey": "frindle",
      "title": "Frindle",
      "author": "Andrew Clements",
      "coverUrl": "https://obob.dog/covers/2026-2027/3-5/frindle.jpg",
      "coverUrls": { "sm": "…", "md": "…" },
      "pageCount": 105,
      "genre": "Realistic fiction",
      "readers": [ { "displayName": "Rosie", "status": "reading", "percent": 40 } ],
      "readingCount": 1,
      "finishedCount": 0
    }
  ]
}
```

- `books` is every book in the division, so one with an empty `readers` list is a book nobody has picked up yet.
- Each member's `stats` are the same numbers `/reader` gives, for a leaderboard without a code per kid.
- `lastActivityAt` (per member and for the team) is the last progress save or battle answer, so a widget can say "Rosie saved progress 2 hours ago" or skip a redraw when nothing moved.

### `GET /team/activity` and `GET /reader/activity`

What the team's readers did with their books lately, newest first: for a ticker, a feed or a "since you last looked". Reading only: battles, games and answers are not in the feed. Header: `X-Team-Code` or `X-Reader-Code`.

Query:

- `days`: how far back, 1 to 30. Default 7. At most 200 events come back.

```json
{
  "apiVersion": "v1",
  "team": { "name": "The Page Turners", "division": "3-5", "year": "2026-2027", "memberCount": 4 },
  "since": "2026-10-29T14:00:00.000Z",
  "days": 3,
  "count": 3,
  "events": [
    { "kind": "progress_saved", "at": "2026-10-31T02:10:00.000Z", "reader": { "displayName": "Raleigh" }, "book": { "bookKey": "frindle", "…": "…" }, "format": "print", "readingNumber": 1, "page": 64, "chapter": null, "percent": 61 },
    { "kind": "book_finished", "at": "2026-10-30T01:30:00.000Z", "reader": { "displayName": "Rosie" }, "book": { "bookKey": "holes", "…": "…" }, "format": "audio", "readingNumber": 1 },
    { "kind": "book_added", "at": "2026-10-29T22:05:00.000Z", "reader": { "displayName": "Rosie" }, "book": { "bookKey": "wonder", "…": "…" }, "format": "print", "readingNumber": 0 }
  ]
}
```

Every event has these fields:

- `kind`: `book_added`, `book_started`, `progress_saved` or `book_finished`.
- `at`: when it happened, ISO 8601 in UTC. Events are sorted by it, newest first.
- `reader`: `{ "displayName" }`, the reader's display name and nothing else.
- `book`: the book reference (key, title, author, covers, page and chapter counts, genre).
- `format`: `print` or `audio`, how the reader is reading it.
- `readingNumber`: 0 for a book on the to-read pile, 1 for a first read, 2 for a re-read.

By kind:

- `book_added`: the reader put the book on their to-read pile. No extra fields; `readingNumber` is 0.
- `book_started`: the reader marked the book as reading. No extra fields.
- `progress_saved`: the reader logged where they are. Adds `page` and `chapter` (one of them, the other `null`, as the reader logs it) and `percent` (0 to 99 against the book's length, or `null` when the book's length isn't known).
- `book_finished`: the reader marked the book as finished. No extra fields; a re-read has `readingNumber` 2 or more.

One of each:

```json
{
  "kind": "book_added",
  "at": "2026-10-29T22:05:00.000Z",
  "reader": { "displayName": "Rosie" },
  "book": { "bookKey": "frindle", "title": "Frindle", "author": "Andrew Clements", "authorPronunciation": null, "coverUrl": "https://obob.dog/covers/2026-2027/3-5/frindle.jpg", "coverUrls": { "sm": "…", "md": "…" }, "pageCount": 105, "chapterCount": 15, "genre": "Realistic fiction" },
  "format": "print",
  "readingNumber": 0
}
```

```json
{
  "kind": "book_started",
  "at": "2026-10-30T01:12:40.000Z",
  "reader": { "displayName": "Rosie" },
  "book": { "bookKey": "frindle", "title": "Frindle", "author": "Andrew Clements", "authorPronunciation": null, "coverUrl": "https://obob.dog/covers/2026-2027/3-5/frindle.jpg", "coverUrls": { "sm": "…", "md": "…" }, "pageCount": 105, "chapterCount": 15, "genre": "Realistic fiction" },
  "format": "print",
  "readingNumber": 1
}
```

```json
{
  "kind": "progress_saved",
  "at": "2026-10-31T02:10:00.000Z",
  "reader": { "displayName": "Raleigh" },
  "book": { "bookKey": "frindle", "title": "Frindle", "author": "Andrew Clements", "authorPronunciation": null, "coverUrl": "https://obob.dog/covers/2026-2027/3-5/frindle.jpg", "coverUrls": { "sm": "…", "md": "…" }, "pageCount": 105, "chapterCount": 15, "genre": "Realistic fiction" },
  "format": "audio",
  "readingNumber": 1,
  "page": null,
  "chapter": 9,
  "percent": 60
}
```

```json
{
  "kind": "book_finished",
  "at": "2026-11-01T03:30:00.000Z",
  "reader": { "displayName": "Rosie" },
  "book": { "bookKey": "frindle", "title": "Frindle", "author": "Andrew Clements", "authorPronunciation": null, "coverUrl": "https://obob.dog/covers/2026-2027/3-5/frindle.jpg", "coverUrls": { "sm": "…", "md": "…" }, "pageCount": 105, "chapterCount": 15, "genre": "Realistic fiction" },
  "format": "print",
  "readingNumber": 1
}
```

The reader feed has the same shape with `reader` in place of `team`.

### `GET /me`

The API key this request came in with and where it stands. Free: it is never counted, so a setup screen or a fuel gauge can ask as often as it likes.

```json
{
  "apiVersion": "v1",
  "key": {
    "name": "Kitchen screen",
    "description": "A tablet on the fridge that shows today's question and flips to the answer.",
    "url": "https://github.com/example/kitchen-screen",
    "prefix": "obob_k7m2p9qa",
    "createdAt": "2026-10-30T18:02:11.000Z",
    "lastUsedAt": "2026-11-01T14:00:00.000Z"
  },
  "rateLimit": { "limit": 60, "remaining": 57, "windowSeconds": 600, "resetsAt": "2026-11-01T14:09:12.000Z", "counted": false },
  "terms": { "version": "2026-10-30", "acceptedAt": "2026-10-30T18:01:40.000Z" }
}
```

### `GET /openapi.json`

The whole API as an OpenAPI 3.1 document, public, no API key, for generating a client or checking shapes.

## Errors

| Status and code | Meaning |
| --- | --- |
| `401 unauthorized` | No API key, or a revoked one |
| `403 terms_not_accepted` | The terms changed; agree to the new version on the developers page |
| `400 missing_scope` | The daily question needs a reader header, a team header or `division=` |
| `400 code_in_url` | A code was put in the query string; send it in the header |
| `400 missing_reader`, `invalid_reader` | No or malformed `X-Reader-Code` |
| `400 missing_team`, `invalid_team_code` | No or malformed `X-Team-Code` |
| `400 invalid_division`, `invalid_year`, `invalid_type`, `invalid_books`, `invalid_days`, `bad_request` | A bad parameter; the message says what to fix |
| `404 not_found` | No reader or team with that code this season, or no such season |
| `429 rate_limited` | Slow down; `Retry-After` says how long. A 304 from `If-None-Match` never gets here |
| `500 server_error` | Our side; try again |

## Not built yet

A list of questions (a policy decision for now), recording answers from your app (so "haven't answered" only knows about battles played on obob.dog), per-API-key usage stats, anything but `GET`. Something missing? Tell us at <hello@obob.dog>.
