A lightweight Cloudflare Workers api and proxy service for HERE API endpoints, iOS universal links, etc. with Sentry monitoring.
WW API is open source, like the WitnessWork app
it serves. Anyone can read the code, the protocol docs in docs/,
and the generated API reference at /docs.
Since the whole contract is public, there's nothing to gain by obscuring it:
short or cryptic field names, headers, or paths would only make the API harder
to read, debug, and build on.
So the API aims to be standard REST, carefully maintained, and easy for people to read:
- Plain names. Fields, headers, and query parameters are spelled out in
full (
payloadandsignature, notpands). Units go in the name or the description (durationSeconds,expiresAtin epoch ms). - Standard HTTP. The right methods and status codes, JSON bodies, one
error envelope (
{ "ok": false, "error", "code" }), andRetry-Afteron every 429 and 503. Where a route departs from REST on purpose, its docs say why. For example, the Buddies relay and the accounts handoff and backup ops usePOST /{op}so ids stay out of URLs, which production logs, while account sessions are plain resources (POST /accounts/v1/sessions,DELETE /accounts/v1/sessions/current). - Documented. Every route and field is described in
/docs, generated from the routes, so you can call the API without reading the source.
Security never depends on the API being hard to read. It comes from Ed25519 signatures, App Attest / Play Integrity, capability secrets, rate limits, and end-to-end encryption (the Buddies relay only ever sees ciphertext).
Some names shipped before this rule. Those still work as legacy aliases (for
example, signed envelopes still accept p/s and the x-buddies-p/-s and
x-ww-p/-s headers from older app builds), but new code and docs use only
the full names.
- pnpm installed
- Cloudflare account
- HERE API key
- Sentry account (optional but recommended for error logging)
pnpm installCopy the example file and fill in your credentials:
cp .dev.vars.example .dev.varsEdit .dev.vars with your actual values:
HERE_API_KEY=your_here_api_key
SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-idRun the development server:
pnpm run devThe service will be available at http://localhost:8787
# Autocomplete
curl "http://localhost:8787/autocomplete?q=cincinnati&limit=5"
# Geocode
curl "http://localhost:8787/geocode?q=48 Muir+Rd,+Rogers,+KY+41365"
# Health check
curl "http://localhost:8787/health"Set secrets (one-time setup):
pnpm exec wrangler secret put HERE_API_KEY
# Paste your HERE API key when prompted
pnpm exec wrangler secret put SENTRY_DSN
# Paste your Sentry DSN when promptedVerify secrets are set:
pnpm exec wrangler secret listDeploy to Cloudflare Workers:
pnpm run deployThe project includes CI/CD that deploys automatically on git tags.
Go to your repo → Settings → Secrets and variables → Actions
Add these secrets:
CLOUDFLARE_API_TOKEN- Get from Cloudflare DashboardHERE_API_KEY- Your HERE API keySENTRY_DSN- Your Sentry DSN
- Go to Cloudflare API Tokens
- Click "Create Token"
- Use "Edit Cloudflare Workers" template
- Copy the token and add it to GitHub secrets as
CLOUDFLARE_API_TOKEN
git tag v1.0.0
git push origin v1.0.0GitHub Actions will automatically deploy to Cloudflare Workers. Monitor deployment in the Actions tab.
Full API reference: /docs (Scalar), generated from the routes; the raw OpenAPI 3.1 document is at /openapi.json. Locally: http://localhost:8787/docs.
Signed ops send a JSON envelope:
{ "payload": "<base64url of the UTF-8 JSON request fields>", "signature": "<base64url Ed25519 signature>" }signature covers the op's context line (ww-buddies/v1\n<op>\n or
ww-account/v1\n<op>\n) followed by the exact decoded payload bytes. Routes
whose body is raw bytes, or that have no body (blob/put, backup/put, the
live WebSocket), send the same two values in x-buddies-payload /
x-buddies-signature or x-ww-payload / x-ww-signature. Each op in /docs
lists its decoded payload fields. The wire contract is
docs/buddies-protocol.md.
App versions with account sessions sign far less: they sign only to open a
session (and to delete backups), and call every other accounts route with
Authorization: Bearer <accessToken> (below).
Anonymous accounts (a random id and a public key). An app proves it's WitnessWork once per session and then calls with short-lived bearer tokens:
POST /accounts/v1/sessionsopens a session: an envelope self-signed by the account key (opsession/open) carrying an attestation fromPOST /attestation/v1/challenge(purposeaccount-session, both hasheshex(SHA-256("ww-account/v1/session\n" + accountPub))). Answers 201 with an access token (a 15-minute JWT), a refresh token, andLocation: /accounts/v1/sessions/current. Sessions last 90 days.POST /accounts/v1/sessions/refreshtrades{ refreshToken }for a new pair; each refresh token works once (plus a 60-second grace for a lost response, which gets the same new refresh token again), and presenting an older one ends the session. Refresh tokens are opaque strings (<sessionId>.<secret>.<mac>); one the worker didn't issue is 401 and never ends a session.DELETE /accounts/v1/sessions/currentends the token's session.GET /accounts/v1/accountreads the token's account.- Handoff (
/accounts/v1/handoff/*) and backup (/accounts/v1/backup/*) take the bearer token too, asPOST /{op}with ids in bodies. - Cloud Sync (
GET /accounts/v1/sync,POST /accounts/v1/sync/put|list|get|delete, and theGET /accounts/v1/sync/liveWebSocket): each device's end-to-end encrypted snapshot in a slot, changes streamed to the account's other devices. Behind KVsync:enabledandsync:entitlement; seeAGENTS.md.
Older app versions authenticate the pre-session way (signed envelopes,
account/register, account/info) while the legacy switch (KV
auth:legacy) is on; once it's off they get 426 upgrade_required.
GET /app/v1/config (no auth) tells the app its minimum version, so it can
show Update Required first. The runbook is in AGENTS.md, "Turning off
backward compatibility".
The server half of Shared Cars (witness-work ADR 0037): one Durable Object
per shared space holding sealed records, members by token hash and X25519
public key (each memberId commits to its key), wrapped keys, and invites,
for up to 10 members. Every op is POST /spaces/v1/{op} (create, sync,
put, invite/create, invite/delete, join, leave, member/remove,
rekey, delete) with Authorization: Bearer <accessToken>; ids and
member tokens travel in the body, never the URL. GET /spaces/v1/live is a
WebSocket (ids in x-ww-space-auth, the member token in x-ww-space-token) that
says hello, then changed. Behind KV spaces:enabled; free. The wire
contract is witness-work's docs/shared-cars-protocol.md; the runbook is in
AGENTS.md, "Shared Spaces".
Proxies to HERE Autocomplete API.
Example request:
curl "https://your-worker.workers.dev/autocomplete?q=Seattle&limit=5"Query parameters:
q: Search query (required)limit: Max number of suggestionsin: Geographic filter (e.g.,circle:47.6,-122.3;r=50000)- All other HERE Autocomplete API parameters and docs
Note: The apiKey parameter is automatically injected by the proxy. Any apiKey sent by the client will be removed.
Proxies to HERE Geocode API.
Example request:
curl "https://your-worker.workers.dev/geocode?q=1600+Amphitheatre+Parkway"Query parameters:
q: Address to geocode (required)- All other HERE Geocode API parameters and docs
Note: The apiKey parameter is automatically injected by the proxy. Any apiKey sent by the client will be removed.
Serves the AASA JSON used by iOS to validate universal links. Lists the prod
(com.leviwilkerson.jwtime), beta (com.leviwilkerson.jwtimebeta), and dev
(com.leviwilkerson.jwtimedev) bundle IDs, prefixed with APPLE_TEAM_ID.
Matches /contacts#<payload>, /buddies/invite#1…, and
/accounts/handoff#1…, plus the short paths older app versions put in links
(/c#<payload>, any URL under /c/*, /b#1…, /h#1…). Those links are
already in people's messages, so the short paths stay listed and served for
good; dropping one would open its links in Safari instead of the app.
Universal-link landing page for shared WitnessWork contacts
(/contacts#<payload>). iOS hands the link off to the WitnessWork app when
installed; otherwise this endpoint serves a fallback HTML page with an App
Store CTA. /c serves the same page for links older app versions shared.
The page, accessibility labels, and Open Graph/Twitter preview strings support
the same 18 locales as witness-work/src/lib/locales.ts. Pass ?lang=pt-pt
before the fragment (/contacts?lang=pt-pt#<payload>) to choose a language explicitly;
otherwise the worker uses Accept-Language preferences, including quality
weights, and defaults to English (en-us). Unsupported or malformed lang
values fall back to browser preferences. Regional variants fall back to the
same language; Portuguese defaults to pt-br, and Chinese uses Simplified
unless Traditional script or a Taiwan/Hong Kong/Macau region is requested.
Responses include Content-Language and Vary: Accept-Language. Preview
crawlers without a language preference receive English unless lang is set.
The og:url is the bare page path (/contacts or /c), without language or
contact data.
The same localization rules apply to legacy /c/:payload links.
The payload is an opaque gzip + base64url–encoded contact export produced by
the app. It rides in the URL fragment, which clients never send, so the worker
never sees it; the page's inline script builds the
witnesswork://import-contact/<payload> "Open app" link in the browser.
Legacy links from older app versions, with the payload in the path. Serves the
same page with a server-rendered "Open app" link; the payload stays out of
og:url and the other meta tags, and Sentry events redact it to
/c/[redacted]. The worker never decodes it.
Device attestation for the whole API: App Attest on iOS, Play Integrity on
Android. Code in src/attestation/.
POST /attestation/v1/challengeissues a one-time challenge: App Attest v1 (empty body), App Attest v2 (bind,enroll, orassert), or Play Integrity. v2 and Play Integrity challenges are bound to one protected request: itspurpose, ids, and hashes.POST /attestation/v1/registerbinds an App Attest key to the install id, or enrolls a v2 recovery token. Play Integrity has no registration step.POST /attestation/v1/verifyis an attested no-op for the app's developer tools (purposeattestation-verify; the oldernotes-import-verifyis accepted too).GET /attestation/v1/statuslists the accepted providers ascapabilities(App Attest always; Play Integrity once configured), the same objectGET /notes-import/statuscarries.providersrepeats it for builds that read the old name.
Each protected route verifies an assertion signed for its own purpose
(registry: src/attestation/purposes.ts). New routes call
requireAttestation (src/attestation/http.ts), which takes App Attest v2 or
Play Integrity. Purposes are signed by installed apps, so a shipped one is
never renamed.
The old paths POST /notes-import/challenge, /notes-import/attest, and
/notes-import/verify run the same handlers for deployed apps while the
Notes Import legacy switch is on (426 otherwise; see
Notes Import), adding
Deprecation: @1792022400 (2026-10-15) and
Link: </attestation/v1/…>; rel="successor-version". Each hit logs
deprecated path: <old> -> <new> (route names only). Remove them once the
minimum app version uses the new paths and those logs go quiet.
The model side of Scribe AI, the in-app assistant that logs time, plans time,
records visits and updates contacts by proposing changes the User reviews.
The app owns the tools and runs every one of them on the device; ww-api runs
one model step per request and stores no conversation content. The wire
contract is the app repo's
docs/scribe-ai-protocol.md
(witness-work ADR 0032); /docs has each route's OpenAPI entry. Code in
src/scribe/.
| Route | Auth | Answer |
|---|---|---|
GET /scribe/v1/status |
none | { enabled, reason, protocol: { min, max }, minAppVersion, disabledTools, limits }, the same for everyone, reused per isolate for 30 s |
GET /scribe/v1/usage |
bearer | the account's usage snapshot (windows, limited, resetsAt); read-only |
POST /scribe/v1/step |
bearer | one model step as SSE: start, then text / tool_call, then exactly one done or error |
POST /scribe/v1/transcribe |
bearer | Import from Paper: one page image to text, metered like a step |
POST /admin/scribe/reset |
x-ww-admin-token |
sets an account's windows back to unused (404 without the token) |
- One step per request. The app sends the whole conversation, its tool
definitions, and an app context each step; the server validates their
shape and size, adds its static system prompt, and streams the model's text
and tool calls back. Tools have no server-side
execute. Write tools only propose: nothing changes until the User taps Add in the app. - Metering is in units,
ceil(uncached input + 0.1 × cached input + 5 × output), charged per step to a per-accountScribeMeterDurable Object. Free accounts have a monthly window; Supporters a 5-hour session window and a weekly window. A turn admitted at step 0 may finish over the limit within the turn grace. Admission also checks the step's idempotency key and the active-step cap. - Model:
openai/gpt-6-lunaon Azure through OpenRouter, ZDR-only (zdr: true,data_collection: 'deny', the provider allowlist, fail closed; ADR 0008). Status needs an allowlisted host whose metadata liststools; KVscribe:modelcan name a fallback. - Privacy: logs, Sentry, and the
api_scribe_step/api_scribe_transcribeanalytics events carry structure only (tool names, counts, tokens, units, timings, finish), never messages, arguments, results, context, tool descriptions, or provider error bodies. - Configuration: every limit and switch resolves KV →
SCRIBE_*environment variable → default. The KV keys and the Notes Import cutover runbook are in AGENTS.md.
One-time setup (beyond HERE/Sentry):
pnpm exec wrangler kv namespace create NOTES_KV
# → paste the printed id into wrangler.toml [[kv_namespaces]] id
pnpm exec wrangler secret put OPENROUTER_API_KEY
pnpm exec wrangler secret put REVENUECAT_API_KEY
pnpm exec wrangler secret put ADMIN_API_TOKEN
pnpm exec wrangler secret put ADMIN_API_TOKEN --env dev # use a DIFFERENT valueGenerate distinct prod/dev tokens with openssl rand -base64 32, configure the
Wrangler secrets above, copy .env.example to the gitignored .env, and set the
matching local tokens plus the dev Worker URL. Then run:
pnpm run admin:reset-usage <accountId> # production
pnpm run admin:reset-usage --dev <accountId> # developmentIt sets every Scribe AI window of the account back to unused, and its paper
pages for the day. Find an account id from a Buddies inbox with
POST /admin/accounts/lookup. --notes-import resets the old Notes Import
meter instead (POST /admin/notes-import/reset), which answers only while the
legacy switch below is on. Missing configuration or bad auth returns 404.
Never reuse NOTES_IMPORT_DEV_BYPASS_TOKEN as the admin secret.
Scribe AI replaced Notes Import. Every /notes-import/* route except
GET /notes-import/status answers 426 upgrade_required (no Retry-After)
unless KV scribe:legacy-notes-import is on, before its rate limit, auth,
or kill switch (src/scribe/legacy.ts); GET /notes-import/usage is 404, as
before the route, so opening an older composer doesn't put the whole app
behind Update Required. GET /notes-import/status stays,
because older app versions read attestation capabilities from it to open
account sessions; newer ones read GET /attestation/v1/status. The code stays,
unreachable while the switch is off, until a follow-up deletes it. Its
contract is in /docs and this file's git history.
The server answers whether an account is a Supporter from its own record
(witness-work ADR 0029, src/accounts/supporter.ts), kept current by
RevenueCat's webhook. Who counts is ADR 0014's rule (src/revenuecat.ts).
POST /accounts/v1/revenuecat-customers(Bearer){ appUserId }→{ ok, supporter, expiresAt }.appUserIdis the RevenueCat app user id the app is logged in as. The server reads that customer from RevenueCat, stores what RevenueCat says, links it to the token's account, and answers the account's status. Repeating it reads RevenueCat again. A customer RevenueCat has never seen isn't linked. At most 5 customers an account and 5 accounts a customer; a new link past either replaces the least recently linked. RevenueCat unreachable is 503unavailable. Besides the read tier per IP, links count against the register tier (60 a minute) per account.GET /accounts/v1/supporter(Bearer) →{ ok, supporter, expiresAt, customers }.POST /revenuecat/v1/webhook: RevenueCat's servers only, with the integration's Authorization header (Bearer <REVENUECAT_WEBHOOK_AUTHORIZATION>). Each customer an event names that some account links is read again from RevenueCat's REST API; unlinked customers are ignored. Idempotent by event id; RevenueCat trouble is 503 so RevenueCat retries.- A record read within the last day is the answer until its expiry; an older
one is read again first, as is a Supporter whose expiry passed since it was
read (one read tells a renewal from a lapse). Each RevenueCat read gives up
after 5 seconds. RevenueCat unreachable then: route planning answers
503
supporter_check_failed; Scribe AI (and Notes Import) count the User as free, withsupporterCheckFailed: true.
Supporter-only shortest driving order for the app's "Plan today's route". Sends
the stops to HERE Waypoints Sequence v8 (findsequence2), which returns the
fastest visiting order (live traffic, open path ending at the last stop) for one
transaction per request.
Request (JSON; any other field is rejected), with Authorization: Bearer <access token>:
{
"start": { "lat": 39.1031, "lng": -84.512 },
"stops": [{ "lat": 39.11, "lng": -84.5 }, { "lat": 39.12, "lng": -84.49 }]
}The token's account must be a Supporter (above),
and a suspended account is refused (403 suspended). Limits are per account.
Legacy (no Authorization header; app versions before sessions): the body
also carries "accountId", the RevenueCat app user id, which RevenueCat must
say is a Supporter; it's also the limits' key. Served while the legacy switch
(KV auth:legacy) is on, and 426 upgrade_required once it's off.
1–10 stops. HERE receives only the coordinates, under opaque ids (start,
s0…), in a form POST body. Neither the coordinates nor any id are logged or
stored.
Response:
{ "order": [1, 0], "distanceMeters": 4321, "durationSeconds": 601, "remainingToday": 9 }order lists indices into stops in visiting order.
Errors carry { "ok": false, "error", "code" } (error is a message;
branch on code): bad_request (400), unauthorized (401, bearer),
payload_too_large (413), supporter_required / suspended (403),
upgrade_required (426, legacy), supporter_check_failed (503 — RevenueCat
unreachable; fails closed), unavailable (503 — kill switch), daily_limit /
rate_limited (429), no_route (422), upstream_error (502). Every 429 and
503 sends Retry-After and repeats it as retryAfter (and, for older builds,
retryAfterSeconds).
The driving route through the points in the order given, so the Android app
can draw roads on the route map and time each leg (iOS asks MapKit on the
device). One HERE Routing v8 transaction per request: every point between the
first and last is a stopover via, so the route comes back as one section per
leg.
Request (JSON; any other field is rejected), with Authorization: Bearer <access token>: 2–12 points (the start, the stops, and a finish elsewhere).
{ "points": [{ "lat": 39.1031, "lng": -84.512 }, { "lat": 39.11, "lng": -84.5 }] }Any account with a session, Supporter or not; a suspended account is refused
(403 suspended). No legacy form. Limits are per account and separate from
optimizations (100 a day, 10 a minute). Routing v8 takes waypoints only in the
query string, so the coordinates are in HERE's URL; the call uses an
uninstrumented fetch, so the URL never reaches a trace, and nothing logs or
stores the coordinates.
Response: one leg per consecutive pair of points. coordinates is the
road shape as flat [lat, lng, lat, lng, …], rounded to 5 decimals.
{
"legs": [{ "distanceMeters": 4321, "durationSeconds": 601, "coordinates": [39.1031, -84.512, 39.11, -84.5] }],
"remainingToday": 99
}Errors as for optimize: bad_request (400), unauthorized (401),
suspended (403), payload_too_large (413), no_route (422),
daily_limit / rate_limited (429), upstream_error (502), unavailable /
disabled (503).
Health check endpoint, and the clock clients calibrate against: it sends
Cache-Control: no-store and a Date header, and does no I/O.
Response:
{
"status": "ok",
"timestamp": "2024-11-27T12:34:56.789Z",
"serverTime": 1732710896789,
"versionId": "…",
"deployedAt": "…"
}Every JSON route answers errors with one envelope (src/errors.ts):
{ "ok": false, "error": "rate_limited", "code": "rate_limited", "retryAfter": 60 }codeis always the stable, machine-readable code; read it first.erroris the same code everywhere except attestation, Notes Import, Scribe AI, and route planning, where it's a human-readable message.- Every 429 and 503 sends
Retry-After(seconds) and repeats it asretryAfter, except quotas no wait can free (Notes Import'slimit_reachedandrefinement_limit, Buddies'limit). Scribe AI's 402limit_reachedsends it too, until the full window resets. - Routes add their own fields beside these (
reason,action,credits,serverTime, HERE's own error fields on the place-search proxy). - A bearer route's missing, expired, or invalid token is 401
unauthorizedwithWWW-Authenticate: Bearer error="invalid_token": refresh once and retry. A request authenticated the pre-session way after the legacy switch is off is 426upgrade_required, withoutRetry-After. /geocodeand/autocompletepass HERE's status through; a HERE error body keeps its fields and gains the envelope (bad_request,not_found,rate_limited,upstream_error). HERE unreachable or answering non-JSON is 502upstream_error.- The HTML pages (
/contacts,/buddies/invite,/accounts/handoff, and the short/c,/c/:payload,/b,/h) are for browsers and always answer 200. POST /buddies/v1/blob/getanswers a Buddies photo blob's raw bytes (application/octet-stream) on success; its errors use the envelope.
Per-IP limits (RATE_LIMITER, 60/min) count each route family in its own
bucket: places (/geocode, /autocomplete), notes-import, scribe
(/scribe/v1/step, /scribe/v1/transcribe), attestation,
route-planning, admin, and app (/app/v1/*)
(src/rateLimit.ts). The old /notes-import/* attestation paths still
count in notes-import. Refusals are 429 rate_limited with
Retry-After: 60. Buddies and accounts have their own per-caller tiers.
Unexpected errors are 500 server_error and are reported to Sentry.
Client Request
↓
Cloudflare Workers (Global Edge)
↓
Hono Router
↓
API Key Injection & Sanitization
↓
HERE API
↓
Response to Client
(Sentry monitors all errors)
- Runtime: Cloudflare Workers with
nodejs_compatandenable_request_signal - Framework: Hono
- Language: TypeScript
- Package Manager: pnpm
- Monitoring: Sentry
- Wrangler: v4.42.2
Hot Updater runs in separate dev, beta and prod Workers, each with its own D1 catalog and private R2 bucket. See the OTA runbook for resource identities, migration/deployment commands and credential setup.