Build a Seekquel integration

Your reading app can pull a reader's Seekquel library and send their reading back.
Base address
https://api.seekquel.app/reading
Sign-in
The reader approves a code, and your app gets a key
Format
JSON over HTTPS
Cost
Free. No developer account, no review before you start

Quick start

Four steps from nothing to reading a reader's library.
  1. Open a pairing

    Send your app's name. You get back a device code to keep and a user code to show.

    curl -X POST https://api.seekquel.app/reading/pair/start \
      -H "Content-Type: application/json" \
      -H "Accept: application/json" \
      -d '{"device_name": "Shelfbound on Pixel 8", "app": "Shelfbound"}'
    
    {
      "device_code": "…",
      "user_code": "K7QD-M2XP",
      "expires_in": 900,
      "interval": 2
    }
  2. Show the code to the reader

    They enter it in Seekquel under Settings, Integrations, Another reading app, see your app's name, then approve or decline. The code lasts 15 minutes.

  3. Poll for the key

    Poll with the device code, waiting interval seconds between tries. Once the reader approves, the answer carries a key. It is sent once, so store it.

    curl -X POST https://api.seekquel.app/reading/pair/poll \
      -H "Content-Type: application/json" \
      -H "Accept: application/json" \
      -d '{"device_code": "…"}'
    
    {
      "key": "…",
      "api_url": "https://api.seekquel.app/reading",
      "device_id": "…"
    }
  4. Call the API

    Send the key as a bearer token on every request. This one fetches every book that changed since the start of September.

    curl "https://api.seekquel.app/reading/library?since=2026-09-01T00:00:00Z" \
      -H "Authorization: Bearer <key>" \
      -H "Accept: application/json"

How a reader connects

The same exchange used to sign a television into a streaming service.

The reader's Seekquel password never reaches your app. Your app appears in their connected devices, and they can disconnect it there at any time. After that the key is refused.

What to send when you open a pairing

device_name
Required. Shown to the reader before they approve, so name your app and the device. Plain text: no angle brackets or line breaks.
app
Optional, and please send it. Your app's name, the same on every device and platform, so we see one app rather than a list of devices. Up to 60 characters of plain text.
platform
Optional. The operating system or hardware, for the reader's list of connected devices.
client
Optional. Your app's listed identifier, once it has one. Leave it out until then.

What polling answers

200
Approved. The body carries key, api_url and device_id. The key is returned once and never again, so store it.
400 authorization_pending
The reader has not answered yet. Wait interval seconds and poll again.
429 slow_down
You polled faster than interval. Back off and keep polling.
403 access_denied
The reader declined. Stop polling.
400 expired_token
The 15 minutes ran out. Start a new pairing.
400 invalid_grant
The device_code is unknown or was already used. Start a new pairing.

Endpoints

Every call is scoped to the one reader who connected you.

Paths are relative to https://api.seekquel.app/reading. Every endpoint needs the key except the two pairing calls and the health check. The reference is English only, so it stays in step with the API.

Connecting

  • POST/pair/start

    Open a pairing. Returns device_code, user_code, expires_in and interval with a 201. No key needed. The body is shown in the quick start.

  • POST/pair/poll

    Poll with device_code until the reader answers. No key needed. The body is shown in the quick start, and every answer is listed under How a reader connects.

  • GET/healthcheck

    Answers while the door is open. No key needed.

    Response
    {
      "status": "ok"
    }

The reader's library

  • GET/library

    The reader's books, oldest change first. Keep the updated_at of the last book you saw and send it back as since next time.

    since
    Optional. ISO 8601. Only books changed at or after it, so the last book you saw comes back once more. URL-encode it, or end it with Z rather than +00:00.
    limit
    Optional. Page size, 1 to 100. Default 50.
    cursor
    Optional. meta.next_cursor from the previous page. null there means you have reached the end.
    work_id, private_book_id
    Exactly one is set. A private book is one the reader added themselves, and has no slug.
    rating
    0.5 to 5, or null.
    media_kind
    epub, pdf, audio or null.
    progress_percent
    0 to 100, unlike percentage on the reading calls, which is 0 to 1.
    Response
    {
      "data": [
        {
          "id": "01J8Q2W4E6R8T0Y2U4I6O8P0A2",
          "work_id": "01J5ZK8M3QH4R7T2V6W9X0Y1B2",
          "private_book_id": null,
          "edition_id": "01J5ZK8N1C3V5B7N9M1Q3W5E7R",
          "slug": "project-hail-mary",
          "title": "Project Hail Mary",
          "authors": [
            "Andy Weir"
          ],
          "cover_url": "https://…/covers/….jpg",
          "status": "reading",
          "rating": null,
          "media_kind": "epub",
          "started_at": "2026-09-02",
          "finished_at": null,
          "times_read": 0,
          "current_page": 211,
          "page_count": 496,
          "progress_percent": 42.5,
          "shelves": [
            "Science fiction"
          ],
          "updated_at": "2026-09-20T18:42:07+00:00"
        }
      ],
      "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": "…/reading/library?cursor=eyJ1c2VyX2Jvb2tzLnVwZGF0ZWRfYXQiOi…"
      },
      "meta": {
        "path": "…/reading/library",
        "per_page": 50,
        "next_cursor": "eyJ1c2VyX2Jvb2tzLnVwZGF0ZWRfYXQiOi…",
        "prev_cursor": null
      }
    }
  • GET/books/search

    Find a catalogue book to link a document to. Send q, 2 to 200 characters, such as the title and author. Answers up to eight books; authors is one comma-separated string here.

    Response
    {
      "data": [
        {
          "work_id": "01J5ZK8M3QH4R7T2V6W9X0Y1B2",
          "title": "Project Hail Mary",
          "authors": "Andy Weir",
          "year": 2021,
          "page_count": 496
        }
      ]
    }

Documents

A document is created by its first PUT /progress, and until then every call here answers 404. Send the book's title and authors in that first push (or call /metadata straight after it) so it can be matched to a catalogue book; with no title it stays unidentified. Status, rating, sessions and highlights answer 409 document_not_linked until the document is matched, and you can settle that yourself with /link. Every call here answers the document as shown under the first one.

  • GET/documents/{document}

    Which book a document is matched to, and the reader's status and rating for it. {document} is your own stable identifier for the book file, up to 64 characters.

    match_status
    pending (not looked up yet), matched, imported (the reader's own private book), unmatched (nothing in the catalogue cleared the bar, so the reader is asked), unidentified (no title was ever sent), ignored or not_a_book.
    book
    null until the document is matched. Exactly one of work_id and private_book_id is set.
    percentage
    The last position you pushed, 0 to 1.
    Response
    {
      "data": {
        "document": "000a5165b016fb6f25284edae4e6bcda6d0e79d879fbfd1e6902665d347eb373",
        "match_status": "matched",
        "title": "Project Hail Mary",
        "authors": "Andy Weir",
        "percentage": 0.4231,
        "resume": null,
        "details_stored": true,
        "book": {
          "work_id": "01J5ZK8M3QH4R7T2V6W9X0Y1B2",
          "private_book_id": null,
          "title": "Project Hail Mary",
          "status": "reading",
          "rating": null,
          "current_page": 211,
          "page_count": 496
        }
      }
    }
  • PUT/documents/{document}/link

    Say which catalogue book the document is, by work_id (an id from /books/search, or the book's slug). Send null when the book is not in the catalogue, and it becomes the reader's own private book.

    Request
    {
      "work_id": "01J5ZK8M3QH4R7T2V6W9X0Y1B2"
    }
  • PUT/documents/{document}/metadata

    The book file's own details, so we can work out which book it is. Every field is optional; send what the file carries.

    authors
    One string, as the file gives it.
    identifiers
    Free text. Any ISBN-10 or ISBN-13 in it is found, and an ISBN is the surest match there is.
    page_count
    The file's own page count. Used to turn your page numbers into the book's.
    chapters
    Up to 500, in reading order. title is required on each; page and depth are optional.
    Request
    {
      "title": "Project Hail Mary",
      "authors": "Andy Weir",
      "series": null,
      "series_index": null,
      "language": "en",
      "description": "Ryland Grace is the sole survivor on a desperate, last-chance mission…",
      "keywords": "Science fiction, Space",
      "identifiers": "isbn:9780593135204\nuuid:5b1f6a1e-2c0d-4e8a-9b7f-3d2c1b0a9e8f",
      "page_count": 496,
      "format": "epub",
      "filename": "Project Hail Mary - Andy Weir.epub",
      "file_size": 1843221,
      "chapters": [
        {
          "title": "Chapter 1",
          "page": 1,
          "depth": 0
        },
        {
          "title": "Chapter 2",
          "page": 14,
          "depth": 0
        }
      ]
    }
  • PUT/documents/{document}/status

    Set status to want_to_read, reading, paused, read or did_not_finish.

    Request
    {
      "status": "read"
    }
  • PUT/documents/{document}/rating

    The reader's own star rating, 0.5 to 5 in half stars (tenths for a Pro reader who switched precise ratings on), or null to clear it. Rating a want-to-read book marks it read.

    Request
    {
      "rating": 4.5
    }

Reading

  • PUT/progress

    The reader's place in a book. The first push for a document creates it.

    document
    Required. Your stable identifier for the book file, up to 64 characters.
    progress
    Required. Your own position string, up to 2,000 characters. Handed back unchanged by GET /progress.
    percentage
    Required. How far through, as a fraction from 0 to 1 (0.42, not 42).
    chapter, chapter_count
    Optional. The chapter the reader is in, counted from 1, and how many there are.
    device
    Optional. The device's name, shown to the reader beside the position.
    metadata
    Optional. title, authors and filename, which is enough to match the book. /metadata takes the rest.
    Request
    {
      "document": "000a5165b016fb6f25284edae4e6bcda6d0e79d879fbfd1e6902665d347eb373",
      "progress": "epubcfi(/6/24!/4/2/8/1:112)",
      "percentage": 0.4231,
      "chapter": 12,
      "chapter_count": 38,
      "device": "Pixel 8",
      "metadata": {
        "title": "Project Hail Mary",
        "authors": "Andy Weir",
        "filename": "Project Hail Mary - Andy Weir.epub"
      }
    }
    Response
    {
      "document": "000a5165b016fb6f25284edae4e6bcda6d0e79d879fbfd1e6902665d347eb373",
      "timestamp": 1758393727
    }
  • GET/progress/{document}

    Read the last saved place back, whichever device saved it. timestamp is Unix seconds. Answers 404 with an empty body when nothing is stored yet.

    Response
    {
      "document": "000a5165b016fb6f25284edae4e6bcda6d0e79d879fbfd1e6902665d347eb373",
      "progress": "epubcfi(/6/24!/4/2/8/1:112)",
      "percentage": 0.4231,
      "device": "Pixel 8",
      "device_id": null,
      "timestamp": 1758393727
    }
  • POST/sessions

    Reading time for one document, a day at a time, up to 400 days in one call. Accepted with a 202 and credited in the background.

    date
    Required. YYYY-MM-DD on the reader's clock. Not before 2008, and not after tomorrow.
    seconds
    Required. The day's total reading time for this book so far, not the time since your last call. Send a day again as it grows; nothing is counted twice.
    hours
    Optional. The same seconds split by hour of the reader's day, keyed 0 to 23. A map that does not add up is dropped and the day is still credited.
    pages
    Optional. Pages turned that day, in your own page numbers.
    fraction
    Optional. The share of the book read that day, 0 to 1. Preferred over pages when both are sent.
    chapter
    Optional. The chapter reached that day, counted from 1.
    Request
    {
      "document": "000a5165b016fb6f25284edae4e6bcda6d0e79d879fbfd1e6902665d347eb373",
      "days": [
        {
          "date": "2026-09-19",
          "seconds": 2460,
          "pages": 31,
          "chapter": 11,
          "hours": {
            "21": 1800,
            "22": 660
          }
        },
        {
          "date": "2026-09-20",
          "seconds": 1320,
          "fraction": 0.018,
          "chapter": 12
        }
      ]
    }
    Response
    {
      "days_accepted": 2
    }
  • POST/highlights

    Passages the reader marked, with their notes, up to 500 in one call. Accepted with a 202 and saved in the background.

    external_id
    Required. Your stable identifier for the highlight. Sending the same one again updates it rather than adding a second, and one the reader deleted in Seekquel is not brought back.
    text
    Required. The passage, up to 20,000 characters.
    note, chapter, page, created_at, color
    Optional. chapter is the chapter's title, created_at is ISO 8601, color is red, orange, yellow, green, olive, cyan, blue or purple, and any other name is kept without a colour.
    Request
    {
      "document": "000a5165b016fb6f25284edae4e6bcda6d0e79d879fbfd1e6902665d347eb373",
      "highlights": [
        {
          "external_id": "hl-2026-09-20-0412",
          "text": "I penetrated the outer cell membrane with a nanosyringe.",
          "note": "Where it clicks.",
          "chapter": "Chapter 12",
          "page": 204,
          "created_at": "2026-09-20T21:14:03Z",
          "color": "yellow"
        }
      ]
    }
    Response
    {
      "highlights_accepted": 1
    }

Your app

  • PUT/device

    Describe your app and its version, so a reader can tell which client sent what. Every field is optional. The answer carries the stored device under device; its other keys belong to the KOReader add-on and can be ignored.

    Request
    {
      "device_name": "Shelfbound on Pixel 8",
      "platform": "Android 16",
      "app_version": "2.3.0"
    }
  • GET/requests

    Recent requests from your app that did not succeed, newest first: the last 50 over 7 days. Read it before writing to us. A server error carries no message, only its request_id, which is what to quote.

    fields
    On a 422, each refused field with its reasons, as the error itself said.
    request_id
    Our reference for that one request. Also sent on every response as the X-Request-ID header.
    Response
    {
      "data": [
        {
          "at": "2026-09-24T14:36:18+00:00",
          "method": "PUT",
          "path": "/reading/progress",
          "status": 422,
          "type": "validation_error",
          "code": "VALIDATION_FAILED",
          "message": "The percentage field must be between 0 and 1.",
          "fields": {
            "percentage": [
              "The percentage field must be between 0 and 1."
            ]
          },
          "request_id": "01M39XH24XKE132HY5S0E99DZQ"
        }
      ],
      "meta": {
        "kept": 50,
        "kept_days": 7
      }
    }

Errors

Accept: application/json
Send it on every request. Without it, a refused field or an unknown document is answered as a redirect or a web page rather than in the shape below.
401 unauthenticated
The key is missing, wrong, or the reader disconnected your app. Pair again.
404
No document with that identifier yet. Push its position first.
409 document_not_linked
The document is not matched to a book yet. See the note under Documents.
422 validation_error
A field was refused. error.context.fields names each one with its reasons.
429
Over budget. Wait for the Retry-After header.
503 feature_disabled
Reading apps are switched off for now. Try again later; the key stays valid.
5xx
Ours to fix, and we are alerted. GET /requests lists it with its request_id.
{
  "error": {
    "type": "document_not_linked",
    "code": "DOCUMENT_NOT_LINKED",
    "message": "Tell us which book this file is before sending reading time or highlights for it."
  }
}

Limits and boundaries

What a connected app can reach, and how often.
  • Your app reads and writes that reader's own data and nothing else. No parameter widens it.
  • No connected app can change the shared book catalogue. Corrections go through the same review as other readers' corrections.
  • Budgets apply per key and per reader, so a reader with two apps connected shares one budget between them. Over budget, you get a 429 with a Retry-After header: wait that long. Use since on the library instead of reading all of it on a timer.
  • A reader can have five reading apps connected at once. A sixth is refused until they disconnect one.
  • Keep the key on your own server or in the device's secure storage, never in a web page: the API does not answer browsers on other sites. A key unused for 90 days stops working, and your app pairs again with a new code.
  • Highlights from an app we have not listed arrive private, and the reader shares them from their notebook. Once your app is listed, its highlights follow the reader's own setting.

Budgets

/library
60 requests a minute
/progress, /documents, /sessions, /highlights
60 requests a minute, shared
/books/search
30 requests a minute
/device, /requests
10 requests a minute, shared
/pair/start
5 a minute per network address

Getting your app listed

Readers can connect your app before it is listed.

Once it works, write to us. If it holds up when we try it, your app gets its own entry on Seekquel's Integrations screen, with its name and setup steps. Until then readers connect it under Another reading app, which works the same way.

Write to [email protected]