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": [ … ]
}
statusispending(extraction queued; pollGET /articles/:idor wait for sync),ready, orfailed(error_messagesays why;PATCHwith nothing or save the URL again to retry).reading_timeis minutes.content_htmlis sanitized, archived HTML: images point at this server (absolute URLs), scripts and styles are gone. Render it withreader/reader.jsand itsreader.css; highlights anchor to the normalized text of this exact HTML, which is whatcontent_versionidentifies. If a re-extraction changes the HTML,content_versionchanges and highlights with the old version may need re-anchoring by quote (the reader module does this).progress.positionis the normalized text offset of the first visible character, so the same position opens the same paragraph on every device.fractionis for the progress bar.lead_image_urlis the archived copy when there is one, else the publisher's URL.favicon_urlis the site's icon archived at extraction time (the largest declared<link rel=icon>, else/favicon.ico), ornull. The web list shows it in each row with the site name; thumbnails are off by default.
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.
Search
| 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:
- Apply in order: upsert the records in
articles,highlights,folders,tags, then remove whatever is indeletions(record_typeisarticle,highlight,folderortag). Deleting an article on the server tombstones its highlights too, so the client can also cascade. - A record's
revisiononly ever grows; if a local copy has a higher one you already have the newer state (that happens when you receive the response to your own write before the next sync). - Articles in sync carry metadata only. Fetch
GET /articles/:idfor the content when the user opens it, or in the background for offline reading; re-fetch whencontent_versiondiffers from the cached one. cursoris the server's revision number, so it also works as an "is anything new" check againstGET /me'srevision.- A write made while offline should be replayed as the matching
POST/PATCH/DELETE; for progress includeupdated_atso an older device cannot rewind a newer one.