API Conventions
One page of rules shared by every module, so the per-module docs stay short.
Base URLs#
- Backend origin:
https://shanebackend-production.up.railway.app https://shanejli.com/api/*rewrites to the same backend (afterFiles rewrite), so relative/api/...URLs work from the site's own pages.
Errors#
Every error is JSON with a top-level error string — no exceptions. Unhandled throws return 500 {"error":"Internal Server Error"}; an unmatched route returns 404 {"error":"Not Found","path":"/api/nope"}.
Validation failures (bad params, query, or body) return 400 and add a structured details array alongside the summary:
{
"error": "Validation failed: limit: Expected number, received nan; cursor: YYYY-MM-DD",
"details": [
{ "path": "limit", "message": "Expected number, received nan" },
{ "path": "cursor", "message": "YYYY-MM-DD" }
]
}
details[].path is the dot-joined field path (notes.0.text for array elements) and is omitted for object-level errors. error folds the first 5 issues into one readable line and appends (+N more); details always carries every issue. Read details for field-level form errors, error when you just need one message to show.
Auth posture#
- Missing/invalid token on a protected route: 401.
- PAT lacking the required scope: 403
Token missing required scope: <scope>. - A row that exists but is not yours: usually 404, not 403 (deliberately indistinct; loans is the exception and 403s).
Wire format#
- JSON bodies. Request field names are camelCase in most modules; the journal and comment surfaces use snake_case for multi-word request fields (
parent_comment_id,base_version_num,target_version_num). Responses are camelCase everywhere. - Dates that key resources are
YYYY-MM-DDstrings, validated as real calendar dates (2026-02-30 is a 400, not a 500). - Ids are UUIDs. Any case is accepted on input (Swift's
uuidStringis uppercase) and responses always return lowercase, so compare ids case-insensitively or lowercase them first. - Free-text fields are trimmed before validation; whitespace-only input is a 400.
- A NUL byte is a 400 everywhere:
%00anywhere in the URL (details[].pathisurl), or\u0000in any string or key of a JSON body (details[].pathnames the field), or in the file text,titleor filename of a multipart trip upload (details[].pathishtml,titleorfilename). Postgres cannot store it, and before SHAN-553 (SHAN-602 for multipart trips) it came back as a 500.
Text search#
Four endpoints take a free-text term: journal entries and blog posts as q, knowledge entries and vocabulary words as search. All four are case-insensitive substring matches, and the term is taken literally: send it raw.
That last part is worth stating because it stopped being true twice. The match runs on Postgres ILIKE, where % and _ are pattern syntax rather than characters, so a term wrapped straight into %...% turns _ into "any one character" and % into "any run of characters". Until SHAN-545 the two vocabulary surfaces did exactly that, and ?search=_ returned the entire table (48 of 48 rows) instead of the rows containing an underscore, a plausible query against a word list full of snake_case and __init__. All four now escape the term server-side.
So: do not escape the term yourself. Sending snake\_case searches for a literal backslash. A term that finds nothing finds nothing; there is no pattern syntax to reach for.
Pagination#
Most modules paginate by keyset, newest-first, via limit (1 to 100) and cursor. Treat cursor as opaque: read nextCursor off a response and send it back verbatim. Null means done.
The cursor value differs by module. The journal entries cursor is the last entry's DATE (YYYY-MM-DD). Blog and journal version history key on the version number, which is bounded to 1..2147483647 (the range of the int4 column behind it); a larger value is a 400, not a 500 from Postgres. Everything else — trips, loans, tea, scoreboard matches, courses, rng history, journal activity, blog posts — keys on a timestamp, and since SHAN-513 that cursor carries the boundary row's id too, as <iso-timestamp>_<row-id>:
2026-05-24T21:32:37.484Z_5269f919-dfae-4d7a-9320-0036f2554ab0
The id half is not decoration. A timestamp alone is not unique and the ISO form is only millisecond-precise, while the stored column is microsecond-precise, so a cursor built from the timestamp alone could skip rows that shared the boundary row's millisecond — silently, with a 200 and a short page. The id makes the sort key total and lets the server read the boundary row's exact stored timestamp back.
Cursors minted before this change (a bare ISO timestamp, no _) are still accepted, so nothing in flight broke. A malformed cursor is a 400, never a silent reset to page 1.
Knowledge and vocabulary are the exception: they page by limit (1 to 500, default 100) and offset, and echo { total, limit, offset } back so you can compute the page count up front. They have no nextCursor, so you are done when offset + limit >= total. offset runs 0 to 1,000,000 — 10,000 pages at the default size of 100, 2,000 at the 500 maximum, far past any list here — and anything above that is a 400. Before SHAN-529 it had no ceiling, so a value Postgres could not parse as a bigint (1e30, 9223372036854775807) reached the query and came back as a 500.
Offset paging needs the same total sort key for the same reason, and since SHAN-515 it has one: both routes order by (created_at DESC, id DESC). Each page is a separate query, and Postgres promises nothing about how rows that tie on the sort key fall between two of them, so without the id half a walk over offset=0, 100, 200 … could return one entry on two pages and never return another. Offset is still only stable against a still list: rows written between your requests shift everything after them, so an ingest running alongside a long walk can repeat a row. Re-read from offset=0 if you need a consistent snapshot.
Caching and conditional GET#
Every 200 JSON response to a GET carries a weak validator, ETag: W/"...", plus Cache-Control: private, no-cache. Send the validator back as If-None-Match on the next poll and an unchanged resource answers 304 with an empty body instead of re-sending the payload:
BASE=https://shanebackend-production.up.railway.app
ETAG=$(curl -sD - -o /dev/null "$BASE/api/journal/entries" | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
curl -s -o /dev/null -w '%{http_code} %{size_download}' -H "If-None-Match: $ETAG" "$BASE/api/journal/entries"
# 304 0
- The tag is derived from the response body, so any change to the data changes the tag.
- It is weak because gzip is applied at the edge rather than by the API, so identical data ships as more than one byte sequence. Weak comparison is what
If-None-Matchuses anyway, so 304s still work. no-cachemeans "you may store it, but revalidate before reuse". It does not mean "do not store".privateis there because responses vary byAuthorizationwhileVarydoes not list it, so a shared cache must never reuse one across users.- A comma-separated
If-None-Matchlist and*both work, and"abc"matchesW/"abc". - Image routes such as
/api/journal/images/:idopt out. They are immutable and already shipCache-Control: public, max-age=31536000, immutable. - In a browser, read the header off
response.headers.get("ETag"). It is listed inAccess-Control-Expose-Headers, without which fetch() cannot see it.
Optimistic concurrency (If-Match)#
Racy journal mutations (revert, suggestion approve) and blog body edits require an If-Match header carrying the current version number as a plain integer (from GET /api/journal/entries/:date or GET /api/blog/posts/:slug, field currentVersionNum).
- Missing header: 428
- Non-numeric: 400
- Stale: 409 with
{"error":"Version conflict","currentVersionNum":<latest>}so you can rebase and retry.
From a browser, send X-If-Match instead#
X-If-Match is accepted everywhere If-Match is, and browser code must use it. Requests from shanejli.com are same-origin and ride a rewrite through Vercel's edge, which evaluates a real If-Match against the response's ETag. Every 200 JSON response here carries a weak validator, and a weak validator can never satisfy If-Match, which requires strong comparison, so the edge replaced the origin's 200 with 412 PRECONDITION_FAILED after the write had committed. A save that succeeded was reported as a failure, and the retry wrote it a second time. Error responses carry no ETag, which is why the 409 and 428 paths were unaffected (SHAN-487 for the blog, SHAN-489 for the journal). As measured on 2026-09-13 the edge applies this to PATCH and currently lets POST through; send X-If-Match on every conditional write rather than depending on that.
If you call the API directly (a PAT plus curl against the Railway origin), If-Match is fine and remains the documented header. If-Match wins if you send both.
Rate limits#
Per-PAT rolling 60 second buckets (JWT browser sessions bypass); see the bucket table in Auth and Tokens. 429 responses carry Retry-After.
CORS#
Allowed request headers are Content-Type, Authorization, If-Match, X-If-Match, If-None-Match, X-Tea-Pin. ETag is the one exposed response header. A new custom header needs a backend change; the symptom of forgetting is a browser-only "Failed to fetch".