Skip to content

API

Versioning and caching

3 min readUpdated Verified against c761e51 on 2026-09-27

Page actions

ChatGPT and Claude are third-party services. The link gives them this page's public address and a short instruction. This site records nothing.

The two API surfaces and their errors, request ids, which responses the browser and the CDN may cache and how ETags make revalidation cheap, the security headers, and X-Service.

Two surfaces, one body

Every route is served twice. /api/* is the legacy surface whose responses are pinned byte for byte by the golden contract and the iOS app. /api/v1/* is the strict surface: the same handlers and the same bodies, but an unknown endpoint is 404 (not 400) and every error is a structured envelope:

{ "error": { "code": "not_found", "message": "no such endpoint: /api/v1/nope", "request_id": "…" } }

code is not_found (404), invalid_request (a bad parameter, 400) or internal (500); every case is on the error reference. New clients should use /api/v1.

What happens to /api. Nothing is scheduled. The legacy surface is what the golden contract pins and what existing clients and the website call, so it keeps answering byte for byte. It has no retirement date; if one is ever set, it will be announced in the API changelog well ahead, and /api/v1 is already the surface to build on.

webapp/serve.py · _handle_v1 · lines 363–388
def _handle_v1(self, u):
"""/api/v1/* — the same routes and bodies as /api/*, with strict semantics: an unknown
endpoint is 404 (not 400), and every error is {"error": {"code", "message", "request_id"}}.
The legacy /api/* responses stay byte-identical (the golden contract pins them)."""
def err(status, code, message):
body = {'error': {'code': code, 'message': message, 'request_id': self._rid}}
return self._respond(status, json.dumps(body).encode(), 'application/json')
legacy = '/api/' + u.path[len('/api/v1/'):]
try:
body = json.dumps(api(legacy, parse_qs(u.query)), ensure_ascii=False).encode()
seg = legacy.split('/')[2] if legacy.count('/') >= 2 else ''
cache = _CACHE_IMMUTABLE if seg in _CACHEABLE else 'no-store'
return self._respond(200, body, 'application/json; charset=utf-8', cache)
except ApiError as e:
return err(e.status, 'not_found' if e.status == 404 else 'error', e.message)
except (ValueError, IndexError, OverflowError) as e:
if str(e) in ('unknown endpoint', 'missing endpoint'):
return err(404, 'not_found', f'no such endpoint: {u.path}')
return err(400, 'invalid_request', str(e))
except Exception:
import traceback
traceback.print_exc(file=sys.stderr)
try:
if hasattr(_local, 'con'): _local.con.close(); del _local.con
except Exception: pass
return err(500, 'internal', 'internal server error')

Read from the source at build time — view these lines on GitHub at c3d9360.

Source: webapp/serve.py · H._handle_v1, read at build time.

Request ids

Every response carries X-Request-Id, and the same id is written to the access log (with the method, path, status and duration — never the query string, so what people search for is not recorded anywhere). An incoming X-Request-Id, or Vercel’s x-vercel-id, is reused when it is a plain token of 8–128 safe characters; anything else is replaced and never logged as sent. Quote the id when reporting a problem and one request can be followed from the edge to the service.

What may be cached

Only responses that depend on nothing but the immutable database are cacheable — the tuple _CACHEABLE in serve.py: ang, shabad, lines, bani, banis, word, analytics, themes, timing, forms, neighbors, related, line_concepts. They are sent with

Cache-Control: public, max-age=300, s-maxage=3600
ETag: "<sha256 of the body, 32 hex>"

so a browser keeps them for five minutes, the CDN in front of the API for an hour, and a repeat request with If-None-Match is answered 304 Not Modified without a body. Everything else — meta, health, random, search, verify — is Cache-Control: no-store: a search is answered afresh and a health check is never stale. The rule is the same on both surfaces.

webapp/serve.py · _CACHEABLE · lines 184–185
_CACHEABLE = ('ang', 'shabad', 'lines', 'bani', 'banis', 'word', 'analytics', 'themes', 'timing',
'forms', 'neighbors', 'related', 'line_concepts')

Read from the source at build time — view these lines on GitHub at c3d9360.

Source: webapp/serve.py — the cacheable set, read at build time.

A dataset change is a new deploy (the pin is baked into the image), and the deploy workflows purge the CDN after each promotion (vercel cache purge, in deploy-production and deploy-staging), so no cached body outlives the database it came from.

Headers on every response

HeaderValueWhy
X-Content-Type-Optionsnosniffthe body is what the type says
X-Frame-OptionsDENYthe API is never framed
Referrer-Policyno-referrera request never carries where it came from
Strict-Transport-Securitymax-age=31536000honoured on the hosted API
X-Request-Idthe request idone id from the edge to the log
X-Serviceall, or the contexts this service runswhich service answered; the gateway’s routing is verified by it

Limits a client will meet

  • limit ≤ 200 and offset ≤ 1,000,000 on search; ids lists read at most 300 ids; a query is at most 300 characters; every integer is clamped rather than rejected, except where a value is required.
  • The server is bounded: a fixed worker pool, a 15-second socket timeout, idle clients dropped.
  • A composition id that is a permanent gap (see the comp_id rule) is 404 on both surfaces.