Experimental

[email protected]:~$ ./developers

Developers

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

WHY.md

// hello

Welcome to the OBOB.dog API

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. This is new and highly experimental. 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 [email protected].

~/api-keys

// auth

Your API keys

Sign in to create an API key

API keys belong to parent accounts in the Dog Eared Book Club. Sign in (or make an account, it's free), then come back here.

Parent sign in
README.md

// start here

How it works

Every request is identified by an API key, sent as a bearer token. API keys belong to a parent account in the Dog Eared Book Club; make one above after agreeing to the developer terms. Base URL: https://obob.dog/api/v1. Everything is JSON and every response, errors included, says "apiVersion": "v1".

curl https://obob.dog/api/v1/books?division=3-5 \
  -H "Authorization: Bearer obob_your_key_here"
  • Limits. 60 requests per API key every 10 minutes. X-RateLimit-Remaining counts down, X-RateLimit-Reset says when (Unix seconds), a 429 carries Retry-After.
  • Free when nothing changed. Every response has an ETag. Send it back as If-None-Match and a 304 comes back without costing a request. Browsers do this on their own.
  • Codes ride in headers. A reader's login code goes in X-Reader-Code, a team code in X-Team-Code, never in a URL.
  • GET only. Cross-origin is allowed, but an API key in a web page is a key anyone can read. Call from a server when you can.
  • For machines. /api/v1/openapi.json (OpenAPI 3.1, no API key needed) describes it all. Hand an AI assistant /developers.md, this page as Markdown, listed in /llms.txt.
examples/

// quick start

Examples to get you started

READ-SAMPLE and TEAM-ROSIE-DEMO are stand-ins that no reader or team has. Put a real login code from your Parent Hub, or a team code from the coach, in their place.

1. Today's question for a reader

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'

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.

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

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)")'

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.

GET /api/v1/books

GET /api/v1/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 Season, like 2026-2027. Default: the current season.
  • division 3-5, 6-8 or 9-12. Leave it out for every division at once.

Response

{
  "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": "https://obob.dog/_next/image?url=…&w=256&q=75", "md": "https://obob.dog/_next/image?url=…&w=640&q=75" },
      "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
    }
  ]
}

bookKey is the stable handle for a book within a season and division; the daily question takes a list of them. coverUrl is the full-size file; coverUrls.sm (about 256px) and md (640px) are the resized WebP the site itself uses, better for tiles. Every book reference elsewhere (a question, a shelf, a board, an event) carries the same short form, so a tile rarely needs this endpoint.

GET /api/v1/questions/daily

GET /api/v1/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 (see the note at the top). Say who it's for:

Scope (pick one)

  • X-Reader-Code A reader's login code (BARK-7X2M3Q, the one on their bookmark). The question comes from the books they've started, no further than the page they've reached, and skips what they've already answered in a battle on obob.dog. A login code is a password: in this header, never in the URL. A code in the query string gets a 400.
  • X-Team-Code A team code (TEAM-FETCH-7K2M, from the coach). Books any member has started, as far as the furthest reader has got; what any member has answered is skipped.
  • division 3-5, 6-8 or 9-12: whole books, no shelf. Add year for another season and books, a comma-separated list of book keys, 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.

Examples

# a reader's question of the day
curl https://obob.dog/api/v1/questions/daily \
  -H "Authorization: Bearer obob_your_key_here" \
  -H "X-Reader-Code: BARK-7X2M3Q"

# a team's, content questions only
curl "https://obob.dog/api/v1/questions/daily?type=content" \
  -H "Authorization: Bearer obob_your_key_here" \
  -H "X-Team-Code: TEAM-FETCH-7K2M"
/api/v1/questions/daily?division=6-8
/api/v1/questions/daily?division=3-5&books=frindle,holes,wonder,hatchet

Response

{
  "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",
      "authorPronunciation": null,
      "coverUrl": "https://obob.dog/covers/2026-2027/3-5/frindle.jpg",
      "coverUrls": { "sm": "…", "md": "…" },
      "pageCount": 105,
      "chapterCount": 15,
      "genre": "Realistic fiction"
    },
    "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.
  • 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, or every question in reach already answered. Show them; they're written for the screen.
  • 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.
  • Show source.name near the question. Volunteers wrote these; the terms ask you to say so.
GET /api/v1/reader

GET /api/v1/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: a display name, never a school, a parent or an email. The login code opens all of it, so it rides in the X-Reader-Code header, never in the URL.

Example

curl https://obob.dog/api/v1/reader \
  -H "Authorization: Bearer obob_your_key_here" \
  -H "X-Reader-Code: BARK-7X2M3Q"

Response

{
  "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", "author": "Andrew Clements", "coverUrl": "…", "coverUrls": { … }, "pageCount": 105, "genre": "…", … },
      "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. percent comes from the page (or the chapter) against the book's length. 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.

GET /api/v1/team

GET /api/v1/team

The team board: who's on it and who's reading what, per member and per book, with each reader's stats, so you can draw a roster or a bookshelf. The same board teammates see on obob.dog, nothing else. The team code rides in the X-Team-Code header.

Example

curl https://obob.dog/api/v1/team \
  -H "Authorization: Bearer obob_your_key_here" \
  -H "X-Team-Code: TEAM-FETCH-7K2M"

Response

{
  "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": { "answeredThisWeek": 16, "percentRightThisSeason": 70, … } },
      "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 their 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 /api/v1/team/activity

GET /api/v1/team/activity and GET /api/v1/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". Four kinds of event: a book added to the shelf, a book started, a progress save, a book finished. Reading only: battles, games and answers are not in the feed. Display names only. The team feed takes X-Team-Code, the reader feed X-Reader-Code.

Query

  • days How far back, 1 to 30. Default 7. At most 200 events come back.

Example

curl "https://obob.dog/api/v1/team/activity?days=3" \
  -H "Authorization: Bearer obob_your_key_here" \
  -H "X-Team-Code: TEAM-FETCH-7K2M"

Response

{
  "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", "title": "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 }
  ]
}

Event fields

Every event has these:

  • 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

{
  "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
}

{
  "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
}

{
  "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
}

{
  "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
}
GET /api/v1/me

GET /api/v1/me

The API key this request came in with and where it stands: name, description, URL and prefix (what you wrote about the app under ~/api-keys), the rate-limit window, the terms version. Free: never counted, so a setup screen or a fuel gauge can ask as often as it likes.

{
  "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" }
}
errors.log

// when it goes wrong

Errors

Errors are JSON too: { "apiVersion": "v1", "error": { "code": "…", "message": "…" } }.

  • 401 unauthorized: no API key, or a revoked one.
  • 403 terms_not_accepted: the terms changed; agree to the new version on this page.
  • 400 with a specific code (invalid_reader, missing_reader, code_in_url, invalid_division, missing_scope, invalid_days…): 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. Revalidating with If-None-Match never gets here: a 304 isn't counted.

Something missing? Tell us at [email protected]. Recording answers from your app is next on the list.