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.
// 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].
// 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.
// 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-Remainingcounts down,X-RateLimit-Resetsays when (Unix seconds), a 429 carriesRetry-After. - Free when nothing changed. Every response has an
ETag. Send it back asIf-None-Matchand a304comes 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 inX-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.
// 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
Everything we know about a season's books: titles, authors and how to say their names, covers, page counts, descriptions, reading levels.
Query
yearSeason, like2026-2027. Default: the current season.division3-5,6-8or9-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
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-CodeA 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-CodeA 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.division3-5,6-8or9-12: whole books, no shelf. Addyearfor another season andbooks, a comma-separated list of book keys, to narrow it.
Options
typein-which-book,contentorboth(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,hatchetResponse
{
"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.fullis the question as a moderator reads it;prompt.plainis the same line with straight quotes and plain dashes for e-ink screens and voice assistants. For an in-which-book question,answeris "Title by Author" andscope.bookshas the choices.reference.labelis "p. 42", or the part of the book ("Author's note") when the answer isn't on a numbered page.questionisnullwith amessage(why) and ahint(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.namenear the question. Volunteers wrote these; the terms ask you to say so.
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
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 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
daysHow 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:
kindbook_added,book_started,progress_savedorbook_finished.atWhen it happened, ISO 8601 in UTC. Events are sorted by it, newest first.reader{ "displayName" }, the reader's display name and nothing else.bookThe book reference: key, title, author, covers, page and chapter counts, genre.formatprintoraudio, how the reader is reading it.readingNumber0 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;readingNumberis 0.book_started: the reader marked the book as reading. No extra fields.progress_saved: the reader logged where they are. Addspageandchapter(one of them, the othernull, as the reader logs it) andpercent(0 to 99 against the book's length, ornullwhen the book's length isn't known).book_finished: the reader marked the book as finished. No extra fields; a re-read hasreadingNumber2 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
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" }
}// 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.400with 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-Aftersays how long. Revalidating withIf-None-Matchnever gets here: a 304 isn't counted.
Something missing? Tell us at [email protected]. Recording answers from your app is next on the list.
