# Chavivim Finder / Dispatch Handoff API — integration guide

> 🆕 **New.** The `/find` locator's **dispatch handoff**: a stranded caller generates a short **5-digit code** carrying their (optionally live) location plus a few self-entered fields and photos; a **dispatcher** redeems the code in their app to auto-fill the "Create Dispatch Call" form. This guide is for the developer wiring the **dispatch app** to the redeem endpoints (`/api/v1/dispatch-codes/*`, scopes `dispatch:read` / `dispatch:write`). Pair it with the interactive spec on this site (`openapi.yaml`) — that's for trying calls live; this is the prose + copy-paste.

---

## TL;DR

- **Base URL:** `https://worker.chavivim.org/api/v1` — the authenticated API is served **only** on this dedicated worker host (plus the `*.workers.dev` staging host), **not** on the `chavivim.org` apex or division domains (those return `404`).
- **Auth:** `Authorization: Bearer cvk_…` on **every** request. Keep the token server-side.
- **The flow:** a caller on **chavivim.org/find** (or via a WhatsApp location pin) taps *"Send my location to dispatch"* → gets a **5-digit code**. The caller reads it to the dispatcher on the phone. Your app calls `GET /dispatch-codes/{code}` to pull their location + details, and (optionally) **polls** the same endpoint to follow a **live** (moving) caller. When the call is handled, `POST …/resolve` to burn the code.
- **What you get:** GPS `lat`/`lon` (+ `accuracy`), a reverse-geocoded `address`, whether it's `live`, a `callbackNumber`, vehicle `makeModel`, `notes`, a `condition` (mapped to the Dispatch app's own dropdown), a `category`, the caller's **`division`** (incl. the Dispatch **location id**), and any caller-attached **photo URLs**.
- **Ephemeral by design:** codes default to a **60-minute TTL** (admin-configurable, max 240). A cron sweep then deletes the handoff, its location, and its photos — so caller PII never lingers. An expired/resolved code `404`s.
- **Format:** JSON in / JSON out.

---

## Scopes

`dispatch:read`, `dispatch:write` (or `dispatch:*`). A `403` with `code: "insufficient_scope"` means the key lacks the scope.

| action | scope |
|---|---|
| `GET` a code's handoff (redeem / poll) | `dispatch:read` |
| `POST …/resolve` (mark the call handled) | `dispatch:write` |

A key for the dispatch app typically holds **both** (`dispatch:read` + `dispatch:write`, or `dispatch:*`). Mint keys at `worker.chavivim.org/admin/api-keys`.

---

## The handoff payload

`GET /dispatch-codes/{code}` returns the caller's handoff. **Every caller field can be `null`** — which fields the caller was even offered is admin-controlled (see [Admin toggles](#admin-toggles-what-may-be-missing)), so always null-check.

```jsonc
{
  "code": "48291",
  "status": "active",                  // only active codes are returned; expired/resolved → 404
  "createdAt": 1752000000000,          // unix ms
  "expiresAt": 1752003600000,          // unix ms — codes default to a 60-min TTL
  "location": {                        // null until/unless a position exists
    "lat": 41.3387,
    "lon": -74.1668,
    "accuracy": 8,                     // metres, may be null
    "address": "Monroe, NY",           // reverse-geocoded label, may be null
    "updatedAt": 1752000012000,        // unix ms of the latest position (advances on live updates)
    "live": true                       // true once the caller's device has sent live updates since create
  },
  "callbackNumber": "(845) 555-1234",
  "makeModel": "Honda Odyssey",
  "notes": "Rear passenger tire",
  "condition": { "id": 12, "name": "Blowout" },  // id maps to the Dispatch app's condition dropdown
  "category": "Car",                   // Car | Home | Apartment | Other | null
  "division": {                        // the division covering the caller; null if none
    "id": 5,                           // ← the Dispatch LOCATION id (GET /admin/locations)
    "name": "Chavivim Orange County",
    "shortCode": "KJ"                  // join key if you resolve the id yourself
  },
  "images": [                          // caller-attached photos; [] if none
    "https://chavivim.org/find/api/dispatch/image/0526516f0f8c22fb03adcfb1"
  ]
}
```

---

## Endpoints & flows (copy-paste)

`$T` = your token (set `Authorization: Bearer $T` on every call; omitted below for brevity). `$B` = `https://worker.chavivim.org/api/v1`.

### Redeem a code
```
GET /dispatch-codes/{code}                         (scope dispatch:read)
→ { …handoff payload… }
```
- `{code}` is the **5 digits** the caller reads to you. A non-5-digit value → `400 invalid_code`.
- No **active** handoff for that code (never existed, already expired, or already resolved) → `404 not_found`.
- The **first** successful `GET` records a redemption stamp (audit: which key/principal redeemed, when). Subsequent GETs don't overwrite it — so you can poll freely.

```sh
curl "$B/dispatch-codes/48291" -H "Authorization: Bearer $T"
```

### Follow a live caller (poll)
The same `GET` is your poll. While `location.live` is `true`, the caller's device keeps pushing its position and `location.updatedAt` advances. Re-`GET` every **~10–15 s** and re-center your map on the newest `lat`/`lon`. Stop polling when the code `404`s (expired/resolved) or you've dispatched.

```jsonc
// poll loop (pseudocode)
while (dispatching) {
  const r = await GET(`/dispatch-codes/${code}`);
  if (r.status === 404) break;              // expired or resolved
  updateMap(r.location);                     // r.location.live tells you if it's still moving
  await sleep(12_000);
}
```

### Mark the call handled
```
POST /dispatch-codes/{code}/resolve                (scope dispatch:write)
→ { …handoff payload with status:"resolved"… }   // or 404 if not active
```
Burns the code: it stops redeeming (further `GET`s `404`) and live sharing on the caller's side stops. Call this once you've created the dispatch call so a stale code can't be reused.

```sh
curl -X POST "$B/dispatch-codes/48291/resolve" -H "Authorization: Bearer $T"
```

---

## A typical dispatcher-app flow

1. Caller phones in and reads their **5-digit code**.
2. Dispatcher types it → app calls `GET /dispatch-codes/{code}` (`dispatch:read`).
3. App auto-fills the "Create Dispatch Call" form from the payload — location on a map, `callbackNumber`, `makeModel`, `notes`, the `condition` pre-selected via `condition.id`, and the division/location pre-selected via `division.id`. Render the `images[]` as thumbnails.
4. If `location.live` is `true`, **poll** the same endpoint (~every 12 s) and keep the map centered on the caller as they move.
5. On **Dispatch** → create the call, then `POST …/resolve` (`dispatch:write`) to burn the code.

> The token authenticates *the dispatch app*, not an individual dispatcher. Keep it server-side; never ship it to an untrusted client.

---

## Photos

Callers can attach up to **6 photos** (the scene, their vehicle, the damage). They come back in `images[]` as **public but unguessable** URLs on the central host (`https://chavivim.org/find/api/dispatch/image/<id>`) — no auth header needed to load them, so you can drop them straight into `<img src>`. They are **ephemeral**: once the handoff expires and the cron sweep runs, the underlying R2 objects are deleted and the URLs stop resolving. Don't hot-link them into a permanent record — if you need to keep a photo with the dispatch call, **fetch and copy it into your own storage** at redeem time.

---

## Division & Dispatch location id

Each handoff resolves the **Chavivim division** covering the caller (from their geocoded position) and returns it as `division`:

```jsonc
"division": { "id": 5, "name": "Chavivim Orange County", "shortCode": "KJ" }
```

- **`division.id` is the Dispatch _location_ id** — the same numeric id you'd get from `GET /admin/locations` on the Dispatch API (the Dispatch system models divisions as *locations*). Use it to pre-select the division/location on your Create-Call form.
- It's resolved **live** from the Dispatch locations list when a service account is configured, and from a built-in **offline snapshot** otherwise — so it works day-one and flips to live automatically (same behavior as the conditions list).
- **`id` can be `null`** when the caller's division isn't in the Dispatch system (e.g. an international or not-yet-onboarded division) or the locations list is unavailable. In that case `shortCode` is still your join key — resolve the id yourself against your own live `/admin/locations` list.
- `division` itself is `null` only when no Chavivim division covers the location at all.

> Currently mapped: **Orange County (Monroe) → location `5` / `KJ`**, **Catskills → `11` / `C`**. Other areas resolve a division `name` but no Dispatch location id until that division exists in the Dispatch system.

## Conditions

`condition.id` maps to the **Dispatch app's own condition dropdown** (the same list your app already uses). `condition.name` is a label snapshot taken when the caller submitted, so it's safe to show even if your list later changes; but drive any programmatic selection off `condition.id`. The list the caller picks from is served by the locator (`/find/api/dispatch/conditions`) — either a built-in snapshot or, when an admin configures a Dispatch service account, the **live** list pulled from the Dispatch app. Either way the `id`s are the Dispatch app's.

---

## Where codes come from (context)

You don't create codes — callers do, on the **public** (unauthenticated) side. Two paths, both landing in the same `dispatch_handoffs` store you redeem from:

1. **Web** — a caller on `chavivim.org/find` taps *"Send my location to dispatch"* (`POST /find/api/dispatch`; live updates via `POST /find/api/dispatch/{code}/location`; photo upload via `POST /find/api/dispatch/{code}/image`). `source: "web"`.
2. **WhatsApp** — a caller DMs a **location pin** to the sidecar's WhatsApp number; the bot reverse-geocodes it, creates a handoff, and replies the 5-digit code to that chat. `source: "whatsapp"`.

From the dispatcher's side both are identical — a code you `GET`.

---

## Admin toggles (what may be missing)

The whole feature and each caller field are switched at `worker.chavivim.org/admin/dispatch` (global admins). When a field is **off**, the caller is never asked and the payload value is `null` (for photos, `images` is `[]`):

| toggle | affects |
|---|---|
| Enable the dispatch handoff | the whole feature (callers can't generate codes when off) |
| Callback number | `callbackNumber` |
| Condition (call type) | `condition` |
| Vehicle make & model | `makeModel` |
| Notes | `notes` |
| Attach photos | `images` |
| Share live location | whether `location.live` can ever become `true` |
| Code valid for (minutes) | the TTL baked into `expiresAt` (1–240, default 60) |

**Design your app to tolerate any of these being absent** — don't hard-require a field that an admin can turn off.

---

## Errors

Uniform envelope:
```
{ "error": { "code": "not_found", "message": "…" } }
```
Common codes: `unauthorized` (401), `insufficient_scope` (403), `not_found` (404 — no **active** handoff for that code), `invalid_code` (400 — not 5 digits), `method_not_allowed` (405), `unavailable` (503 — content database not configured).

---

## Gotchas

- **A `404` is normal, not an error to alert on** — it just means the code expired, was resolved, or was mistyped. Treat it as "no active handoff" and stop polling.
- **Everything caller-entered can be `null`** (admin toggles + the caller may simply skip a field). Always null-check; never assume `condition`, `callbackNumber`, etc. are present.
- **`location` can be `null`** too (a code can exist before a fix). Guard your map code.
- **`live` vs static** — `location.live` is `false` for a one-shot position and `true` once the device has pushed at least one live update. Only poll when it's `true` (or when you want to catch it turning on); otherwise the position won't change.
- **Photos are ephemeral** — copy them into your own storage at redeem time if you need to keep them; the URLs die with the handoff.
- **Poll politely** — ~10–15 s is plenty; the caller's device only posts every ~12 s anyway.
- **Resolve when done** so a stale code can't be re-redeemed, and to stop the caller's live sharing.
- **Cross-app token** — one key authenticates the dispatch app; keep it server-side (stored only as a hash, shown once at mint).
