Texthyper

Help Texthyper API v1

Texthyper API v1

The JSON API the iOS app and the browser extension use. The base URL is https://app.texthyper.com/api/v1 (the apex answers too, but the app host is the one to configure). Everything lives under /api/v1, speaks JSON both ways, and is authenticated with a bearer token except for POST /login_codes and POST /tokens.

Authorization: Bearer th_…
Content-Type: application/json

The same tokens work with the MCP server at /mcp for AI assistants; see texthyper.com/mcp.

Errors are JSON too: { "error": "Unauthorized" } (401, 404, 400, 429) or { "errors": ["Url is not a valid URL"] } (422, validation messages for the record). Timestamps are ISO 8601 in UTC. IDs are integers.

Authentication

Method Path Body Response
POST /login_codes { email } 202 { sent: true, expires_in: 600 } — emails a six-digit code
POST /tokens { email, code, device_name, platform } 201 { token, user: { id, email_address } }
DELETE /tokens/current 204 — revokes the token making the request

There are no passwords. Logging a device in is two steps: POST /login_codes with the address, then POST /tokens with the address and the code from the email (123456; 123-456 as the email shows it is accepted too). The answer to /login_codes is the same whether or not the address has an account: an unknown address gets an account when sign-ups are open on the instance, and silently nothing when they are closed. Codes expire after 10 minutes, a new one replaces the old, and five wrong guesses burn it (401 { error: "That code didn't work" }).

platform says what is signing in: ios (the iPhone and iPad app), android (the Android app), extension (a browser extension) or api (anything else). The web app uses it to stop suggesting the iPhone app to accounts that have it. Without it the server guesses from the User-Agent: URLSession's default (CFNetwork/…) is ios, a browser's is extension, the rest api.

The token is shown once. Store it in the keychain; it does not expire but the user can revoke it in Settings → API tokens (the device name is what they see there). Rate limits give 429: 10 code requests in 10 minutes per IP and 5 per hour per address, 10 token attempts in 3 minutes per IP. Use autocomplete="one-time-code" / textContentType = .oneTimeCode on the code field: the email ends with @texthyper.com #123456, which is what Apple's autofill keys on.

Account

Method Path Body Response
GET /me { id, email_address, preferences, counts: { unread, liked, archived, highlights }, revision, device_name }
PATCH /me { preferences: { … } } the effective preferences
POST /me/deletion_code 202 { sent: true, expires_in: 600 } — emails a six-digit code to the account's address
DELETE /me { code } 204 — deletes the account and everything in it

Deleting the account takes two steps, so a token on its own can't do it: POST /me/deletion_code emails a code, and DELETE /me with that code deletes. The code works like a login code (ten minutes, five guesses, a new one replaces the old, at most 5 requests an hour per account); a wrong one gives 422 { error: "That code didn't work" } and deletes nothing. On success every token and session is revoked at once, so every device is signed out, and the library is deleted in the background seconds later. There is no undo.

Preferences are the reading settings shared with the web app: theme (light, sepia, dark, black), auto_dark, font (literata, georgia, charter, system, helvetica, atkinson), font_size (1–7), width (1–3), justify, condensed, thumbnails. Send only the keys that changed. Unknown keys are ignored, out-of-range values fall back to the default.

Articles

Article JSON

Lists and sync return metadata only; GET /articles/:id adds the content.

{
  "id": 42,
  "url": "https://scottberkun.com/essays/how-to-make-things-happen/",
  "title": "How to make things happen",
  "author": "Scott Berkun",
  "site_name": "scottberkun.com",
  "domain": "scottberkun.com",
  "excerpt": "How to make things happen, a sample chapter.",
  "word_count": 11000,
  "reading_time": 44,
  "lead_image_url": "https://texthyper.example/rails/active_storage/blobs/proxy/…/lead.jpg",
  "favicon_url": "https://texthyper.example/rails/active_storage/blobs/proxy/…/favicon.png",
  "status": "ready",
  "error_message": null,
  "liked": false,
  "liked_at": null,
  "archived": false,
  "archived_at": null,
  "folder_id": null,
  "tags": ["management"],
  "highlights_count": 2,
  "progress": { "fraction": 0.4, "position": 120, "updated_at": "2026-10-25T10:00:00.000Z" },
  "saved_at": "2026-10-24T09:12:00.000Z",
  "published_at": "2016-10-24T00:00:00.000Z",
  "revision": 10,
  "created_at": "2026-10-24T09:12:00.000Z",
  "updated_at": "2026-10-25T10:00:00.000Z",

  "content_html": "<p>…</p>",
  "content_version": "sha256 of content_html",
  "highlights": [ … ]
}

Endpoints

Method Path Query / body Response
GET /articles `state=unread\ liked\
POST /articles { url, html?, title?, folder_id?, tags?: [names], archived?: bool } 201 the article (pending) — or 200 the existing article if that URL is already saved
GET /articles/:id the article with content_html, content_version, highlights
PATCH /articles/:id { title?, excerpt?, liked?, archived?, folder_id? (null clears), tags?: [names], progress?: { fraction?, position?, updated_at? } } the article
DELETE /articles/:id 204
GET /articles/:id.epub the article as an EPUB 3 (application/epub+zip, attachment) with its archived images embedded; 409 while extraction is pending

Lists are newest saved first and keyset-paginated: pass next_cursor back as cursor until it is null. Cursors are opaque; a bad one is ignored and starts from the top.

html on POST is the page as the client rendered it (extension, share sheet), which gets paywalled and logged-in pages right; without it the server fetches the URL. URLs are normalized (scheme added, host lowercased, fragment and utm_*/fbclid/gclid/ref parameters dropped) and unique per user.

progress.updated_at lets a device that was offline send the time it last read: an update older than the saved progress is ignored instead of rewinding another device's place. Tags on PATCH replace the full set; names are matched case-insensitively and created as needed.

Highlights

{
  "id": 7,
  "article_id": 42,
  "quote": { "exact": "Calling bullshit makes things happen.", "prefix": "move projects forward. ", "suffix": "" },
  "position": { "start": 220, "end": 257 },
  "content_version": "sha256…",
  "color": "yellow",
  "comment": "The whole chapter in one line.",
  "revision": 20,
  "created_at": "…",
  "updated_at": "…"
}

quote and position are the W3C TextQuoteSelector and TextPositionSelector reader/reader.js describeSelection() produces, with 32 characters of context on each side. Send both and the content_version the selection was made on; at least one of quote.exact or position is required. Colors: yellow, green, blue, pink, purple.

Method Path Body Response
GET /articles/:id/highlights [ … ] oldest first
POST /articles/:id/highlights { quote, position, content_version, color?, comment? } 201 the highlight
PATCH /highlights/:id { color?, comment? } (null or "" clears the comment) the highlight
DELETE /highlights/:id 204
GET /highlights/:id/card.png `size=square\ story\

Creating, changing or deleting a highlight also bumps the article's revision and highlights_count.

Folders and tags

Method Path Body Response
GET /folders [{ id, name, position, article_count, revision, created_at, updated_at }] in position order
POST /folders { name, position? } 201
PATCH /folders/:id { name?, position? } the folder
DELETE /folders/:id 204 — articles in it keep existing with folder_id: null
GET /tags [{ id, name, article_count, revision, created_at, updated_at }]
POST /tags { name } 201 (a leading # is stripped)
PATCH /tags/:id { name } the tag
DELETE /tags/:id 204 — removed from every article

Folder and tag names are unique per user (tags case-insensitively); duplicates give 422.

Method Path Query Response
GET /search q, cursor, limit { articles: [ article + "snippet" ], next_cursor }

Full-text over title, author and the article text, ranked. snippet is HTML with the hits wrapped in <mark>; strip the tags or render them. Empty q gives an empty list.

Sync

GET /sync?since=<revision>&limit=<n ≤ 500, default 200>
{
  "articles": [ … ],
  "highlights": [ … ],
  "folders": [ … ],
  "tags": [ … ],
  "deletions": [ { "record_type": "article", "record_id": 42, "revision": 130, "deleted_at": "…" } ],
  "cursor": 131,
  "has_more": false
}

Every create, update and delete on the user's articles, highlights, folders and tags takes the next number from one server-wide sequence and stores it as revision; deletes also write a tombstone with its own revision. GET /sync?since=N returns every record with revision > N, oldest first, across all five lists, at most limit records in total, and cursor is the highest revision included. Keep going while has_more is true, then poll (or sync on foreground) with the last cursor. since=0 (or omitted) is a full download.

Client rules: