Skip to main content

API

A Hono app on Cloudflare Workers, bound to a D1 database. Source: apps/api/src.

Base URLs:

EnvironmentURL
Localhttp://localhost:8787
PR previewA per-version *.workers.dev URL, posted on the PR — see CI/CD
ProductionSet as the PRODUCTION_API_URL repository variable

Conventions

Every request body and query string is validated against a schema from @crag-topo/shared via @hono/zod-validator. A validation failure is a 400 before any handler runs.

Every error has the same shape. One shape, so clients never branch on two:

{
"error": {
"code": "not_found",
"message": "Crag \"nowhere\" not found",
"details": { "bbox": ["Invalid format"] }
}
}

code is one of bad_request, unauthorized, forbidden, not_found, conflict, rate_limited, internal. details is present only for field-level validation problems.

Internal errors never leak. An unhandled exception logs the request id and returns a generic internal. D1 error text and stack traces do not go to a client we do not control.

Every response carries x-request-id and x-revision. The revision is the git SHA the Worker was built from, which is how you confirm a preview is serving the commit you just pushed.

Pagination is keyset, never offset. cursor is opaque. Clients pass back what they were given and never parse it.


GET /health

Liveness plus a real D1 round-trip. Used by the deploy workflows to verify a deployment before declaring it good.

{
"status": "ok",
"revision": "a1b2c3d4e5f6…",
"environment": "preview",
"database": "ok",
"time": 1786988278121
}

Returns 503 if the database round-trip fails; the body shape is identical, with "database": "unreachable".


GET /v1/crags

Browse and search. Auth optional.

QueryTypeNotes
qstringCase-insensitive match on name or region
countrystring(2)ISO 3166-1 alpha-2
routeTypeenumsport, trad, boulder, …
bboxstringminLat,minLng,maxLat,maxLng for map queries. Malformed → 400
cursorstringFrom the previous response
limitint1–100, default 25
{
"crags": [ { "id": "crg_01J…", "slug": "stanage-popular", "name": "Stanage Popular",} ],
"nextCursor": "crg_01J…"
}

GET /v1/crags/:slug/bundle

Everything needed to use one crag offline, in a single response: the crag, its sectors, its photos, its routes, and its route lines.

Why one endpoint instead of four: the "download this crag" button needs one thing to retry when you are on one bar of signal in a car park. Four requests means four ways to end up with a half-downloaded crag.

{
"crag": {},
"sectors": [],
"photos": [],
"routes": [],
"routeLines": []
}

404 with code: "not_found" if the slug is unknown or the crag is soft-deleted.

:::caution Response size This response is unbounded — a large crag returns a large body. Fine at current data volumes, not fine forever. When it stops being fine the fix is paging within the bundle, not four endpoints again. :::


GET /v1/crags/:slug/routes

Filtered route list for one crag.

QueryTypeNotes
sectorIdstring
routeTypeenum
gradeSystemenum
minBand, maxBandint 0–100The lossy normalised band — see data model
minStarsint 0–3
cursor, limit

POST /v1/sync/pull

Delta sync, download direction. Requires auth. Full protocol in Offline-first.

Request:

{
"cursors": { "route": "1735689600000:rte_01J…", "crag": "1735689600000:crg_01J…" },
"cragIds": ["crg_01J…"],
"limit": 500
}

Response:

{
"changes": {
"crag": [],
"sector": [],
"photo": [],
"route": [],
"routeLine": [],
"ascent": [],
"wishlistItem": []
},
"cursors": { "route": "1735689700000:rte_01J…" },
"hasMore": false,
"serverTime": 1786988278121
}

Notes that matter:

  • Soft-deleted records are included. Tombstones are the payload, not noise.
  • A malformed cursor is a 400, not a silent full download.
  • cragIds scopes server-owned content. Empty means everything.
  • User-owned kinds are scoped to the caller regardless.

POST /v1/sync/push

Delta sync, upload direction. Requires auth. Only ascent and wishlistItem are writable.

Request:

{
"mutations": [
{ "kind": "ascent", "op": "upsert", "baseUpdatedAt": null, "record": { "id": "asc_01J…",} }
]
}

Response — one result per mutation, never a single all-or-nothing status:

{
"results": [
{
"id": "asc_01J…",
"kind": "ascent",
"status": "applied",
"serverRecord": null,
"message": null
}
],
"serverTime": 1786988278200
}
statusMeaningClient should
appliedWrittenClear the outbox row
conflictThe stored row moved on; serverRecord holds itToday: clear and let the pull overwrite. See Offline-first
rejectedNot allowed — e.g. another user's recordClear and log; retrying will not help

The whole request never fails because one mutation did. A batch of 100 with one bad record applies 99.


Identity

There are no accounts (ADR-0007). The app mints a local usr_<ULID> on first launch and sends it as the bearer token:

Authorization: Bearer usr_01J4X8ZK9M2N3P4Q5R6S7T8V9W

The server validates the shape and takes the value at face value. A malformed id is a 401; a well-formed one is accepted, whoever sent it.

:::danger This is identity, not authentication Anyone who knows a userId can read and write that user's ticks. That is acceptable with zero users and disposable data, and it is not acceptable at launch.

The Worker therefore returns 501 for the sync endpoints in production unless ALLOW_UNAUTHENTICATED is explicitly set to true. Shipping open writes to real users has to be a deliberate act, not an oversight. :::


Things this API does not have yet

Recorded so nobody assumes they exist:

  • Rate limiting. A public sync endpoint with no limits is a bad idea. Needs an ADR.
  • Write endpoints for guidebook content. Crags and routes are seeded by SQL today. Curation tooling is roadmap phase 3.
  • Image upload / delivery. No R2 binding exists yet.
  • Versioning beyond the /v1 prefix. The prefix is there; there is no deprecation policy behind it.

Testing

apps/api/test/api.test.ts runs inside workerd with a real D1, migrated from the same migration files production uses. Run it:

pnpm --filter @crag-topo/api test