# DroneFleet — Drone-as-First-Responder (DFR) API

**Integration guide for CAD / dispatch systems**
Version 1.0 · API prefix `/api/v1` · JSON over HTTPS

This document describes the REST + real-time API a **Computer-Aided Dispatch (CAD)**
system uses to launch a drone to an incident, watch it fly in real time, receive
on-scene detections, and keep a synchronized incident log. Everything below was
verified against the running API; the request/response shapes are the exact ones the
server produces.

- Machine-readable spec: [`openapi.yaml`](openapi.yaml) (OpenAPI 3.0 — import into
  Postman / Insomnia or codegen a client).
- Runnable samples: [`samples/`](samples/) (Node.js, PowerShell, and a `.http` file).

> **Supported aircraft: MAVLink only.** DroneFleet commands and receives telemetry from
> **MAVLink-compatible autopilots** — ArduPilot / PX4 / Pixhawk-class (e.g. Holybro X500,
> and Blue UAS / NDAA airframes built on such autopilots). Non-MAVLink aircraft — DJI
> consumer drones, or vendor-SDK-only platforms such as Skydio / BRINC — are **not
> supported** without a separate vendor adapter. Telemetry reaches the API through a
> MAVLink bridge — from a real autopilot in flight, or a software-in-the-loop vehicle for
> testing; this API surface is identical either way.

---

## 1. Base URLs

| Environment | Base URL |
|---|---|
| **Production (Azure)** | `https://dronefleet-api.azurewebsites.net` |
| Local dev | `http://localhost:5220` |

All endpoints below are relative to `"<base>/api/v1"`. Real-time hub is at
`"<base>/hubs/fleet"`. Health: `GET <base>/health/live` and `GET <base>/health/ready`.

`GET <base>/` returns a small discovery document (`{ service, version, health,
realtimeHub }`).

---

## 2. Authentication

Every write (deploying a drone, posting a target, logging an event) must be
authenticated. On the public Azure demo, **anonymous callers are read-only** — an
unauthenticated `POST`/`PATCH`/`DELETE` returns `401 READ_ONLY`. Reads are open on the
demo but should be authenticated in production.

### 2a. Client ID + Secret — the CAD install credential (recommended)

A host app installs a **Client ID + Secret** pair, like any standard API integration.
An **organization admin** mints it once and hands it to the CAD:

```
POST /api/v1/api-keys          (requires an ORG_ADMIN bearer token — see 2b)
{ "label": "City CAD - DFR", "scopes": "*", "expiresInDays": 365 }
→ 201 {
    "apiKeyId": "12",
    "clientId": "dfc_8dd8863f60e0c6dc581ad741",   // PUBLIC — safe to store / log / display
    "clientSecret": "dfk_…",                        // shown ONCE — store securely
    "status": "ACTIVE", "scopes": "*", ...
  }
```

The CAD stores `clientId` + `clientSecret` in its config and sends them on every
request — **either** form works with any HTTP client:

```
# HTTP Basic  (client id = username, secret = password)
Authorization: Basic base64("<clientId>:<clientSecret>")

# — or — two headers
X-Api-Key:    <clientId>
X-Api-Secret: <clientSecret>
```

Only `SHA256(secret)` is stored; the secret is returned **exactly once**. An unknown or
wrong client id/secret is a hard `401 INVALID_CLIENT`. `scopes:"*"` = authenticated but
role-less (correct for a CAD); use `scopes:"ORG_ADMIN"` only for a credential that must
manage users/keys.

**Rotate the secret** without re-provisioning the client id (e.g. scheduled rotation):
```
POST /api/v1/api-keys/{id}/rotate-secret  →  { clientId (same), clientSecret (new), … }
```
Revoke with `DELETE /api/v1/api-keys/{id}`. List with `GET /api/v1/api-keys` (never
echoes secrets; shows `clientId`).

> **Legacy single key:** the older `X-Api-Key: <secret>` mode — one opaque `dfk_…` with
> no separate id — still works; the create response's `key` field is that secret. Prefer
> the Client ID + Secret above for new installs.

### 2b. `Authorization: Bearer` — user session

For interactive operators (or to bootstrap the first API key). Exchange an
email + password for a 30-day session token:

```
POST /api/v1/auth/login
{ "email": "pilot@dronefleet.dev", "password": "Passw0rd!" }
→ 200 {
    "token": "<opaque session token>",
    "expiresUtc": "2026-09-04T00:00:00.000Z",
    "user":   { "id": "...", "email": "...", "orgRole": "PILOT", ... },
    "tenant": { "id": "...", "name": "..." }
  }
```

Then send `Authorization: Bearer <token>` on subsequent calls. `POST /api/v1/auth/logout`
revokes it.

> **Demo logins** (Azure): `admin@dronefleet.dev`, `pilot@dronefleet.dev`,
> `analyst@dronefleet.dev` … all password `Passw0rd!`. `admin@…` is `ORG_ADMIN`
> (needed to mint API keys). These are demo credentials — rotate before production use.

### Roles

Org roles are `ORG_ADMIN · ENGINEER · PILOT · ANALYST · VIEWER`. For DFR, a `PILOT`
(or an API key with `scopes:"*"`) can do everything in this document; minting API keys
and managing users requires `ORG_ADMIN`.

### Multi-tenancy

Every credential resolves to exactly one **tenant** (organization). All data you
create or read is automatically scoped to that tenant — you never pass a tenant id.

---

## 3. The DFR incident-response lifecycle

This is the end-to-end flow the CAD drives. Steps 1–4 and 7 are CAD → API calls;
telemetry (5) and detections (6) originate from the **aircraft** and are read/received
by the CAD.

| # | Stage | Call | Result |
|---|---|---|---|
| 0 | (Optional) airspace pre-check | `GET /airspace-auth/facility-map?lat=&lon=` | grid ceiling (ft) + facility for the incident point |
| 1 | Pick an available drone | `GET /vehicles` | choose one with `status:"IDLE"` and the payload you need (e.g. `THERMAL`, `ZOOM`) |
| 2 | **Deploy** to the incident | `POST /missions` → `POST /missions/{id}/start` | mission `FLYING`; a flight plan with one waypoint at the incident location |
| 3 | Log the deployment | `POST /missions/{id}/events` `{kind:"DEPLOYED"}` | timestamped entry on the shared incident log |
| 4 | Watch it fly | SignalR `telemetry` **or** poll `GET /vehicles/{id}/telemetry` | live lat/lon/alt/heading/speed/battery |
| 5 | Receive on-scene detections | SignalR `target` / webhook `target.detected` | person/vehicle detections with lat/lon + confidence |
| 6 | Annotate the incident | `POST /missions/{id}/events` (`ON_STATION`, `TARGET_DETECTED`, `RTB`…) | keeps CAD + drone logs in sync |
| 7 | View live video | `GET /missions/{id}/video` | stream descriptor (WebRTC in production) |
| 8 | Clear the call | `POST /missions/{id}/complete` **or** post a CFS-closing event (§5.5) | mission `COMPLETE`; drone returns to `IDLE` |

**Where does telemetry come from?** The aircraft — a real Pixhawk/PX4-class drone in
flight, or a software-in-the-loop vehicle for testing — streams MAVLink through a bridge
that `POST`s to `/telemetry`, which the API stores and broadcasts. The CAD **reads** it.
For a bench test with no aircraft, the sample program can post telemetry itself
(`--simulate-drone`) so you can see the whole loop.

---

## 4. Flight-safety limits & the geofence (configure your operating area)

`POST /missions` (and any waypoint edit) validates every waypoint against your
organization's **operating area** — a bounding box **or an arbitrary polygon** (for
irregular jurisdictions) plus an altitude ceiling:

| Rule | Default | Error code if violated |
|---|---|---|
| Max altitude | **120 m (~400 ft AGL)** | `ALTITUDE_EXCEEDED` |
| Geofence (lon/lat box) | `[-86.83, 33.40, -86.74, 33.49]` (Vestavia Hills, AL) | `GEOFENCE_VIOLATION` |

These are **per-tenant and configurable** — see §5.7. Until you set your own, the
defaults above apply, so demo coordinates like `lat 33.4405, lon -86.789` work out of
the box. **Set your real operating area before flying** so missions outside it are
rejected and legitimate ones inside it are allowed.

> A quick way to configure it visually: open `samples/geofence-config.html` (draw your
> box on a map and Save). See §5.7 for the API + CLI.

---

## 5. Endpoint reference

Conventions: all bodies are JSON; ids are strings; timestamps are ISO-8601 UTC
(`tsUtc`, `…Utc`). Errors use the shape in §8.

### 5.1 Fleet

#### `GET /vehicles` — list drones
```json
200 → {
  "vehicles": [
    { "id": "1", "name": "Sim Drone 1", "status": "IDLE",
      "lastLat": 33.4405, "lastLon": -86.789, "lastAltM": 0, "battPct": 100,
      "tsUtc": "2026-08-05T12:00:00.000Z",
      "supportedPayloads": ["LIDAR", "RGB", "ZOOM"] }
  ]
}
```
`status` is one of `IDLE · FLYING`. `supportedPayloads` values include
`RGB · ZOOM · THERMAL · LIDAR · MULTISPECTRAL · SPRAY · SPREADER · BEACON_RX`.
For DFR you typically want a drone carrying `ZOOM` and/or `THERMAL`.

`GET /vehicles/{id}` returns a single vehicle in the same shape.

#### `GET /fleet` — unified inventory + live location + status + maintenance
Richer roll-up (joins live location with airframe inventory and maintenance state).
Optional; `GET /vehicles` is sufficient to pick a drone.

### 5.2 Missions (the "deploy")

#### `POST /missions` — create the flight plan
```json
// request
{
  "name": "DFR - 123 Main St (Priority 1)",
  "waypoints": [
    { "seq": 1, "lat": 33.4405, "lon": -86.789, "altM": 90, "holdSec": 30 }
  ]
}
// 201 →
{
  "id": "42", "externalId": "b1e7…", "projectId": null,
  "name": "DFR - 123 Main St (Priority 1)", "status": "DRAFT",
  "createdUtc": "2026-08-05T12:00:00.000Z",
  "waypoints": [ { "seq": 1, "lat": 33.4405, "lon": -86.789, "altM": 90, "holdSec": 30 } ]
}
```
- `waypoints[]` (≥1 required): `seq` (int, order), `lat`, `lon`, `altM` (≤120),
  `holdSec` (optional loiter seconds — use this to orbit the incident).
- `name` optional. `projectId` optional (files the mission under an engagement;
  omit for ad-hoc DFR calls).
- A new mission starts in `DRAFT`.

#### `POST /missions/{id}/start` — launch
```json
200 → { "missionId": "42", "status": "FLYING" }
```
Sets status `FLYING` and broadcasts `missionStatus` over SignalR.

#### `POST /missions/{id}/complete` — clear the call
```json
200 → { "missionId": "42", "status": "COMPLETE" }
```
Sets status `COMPLETE` and returns any drone that flew it to `IDLE`.

#### Other mission endpoints
- `GET /missions` — list (add `?projectId=<id|none>` to filter).
- `GET /missions/{id}` — full mission incl. waypoints.
- `PATCH /missions/{id}` `{ name?, projectId? }` — rename / re-file. Name edits are
  locked once the mission is `FLYING`/`COMPLETE`; re-filing `projectId` is allowed any time.
- Waypoint editing (`DRAFT`/`UPLOADED` only): `POST /missions/{id}/waypoints`,
  `PATCH …/waypoints/{wpId}`, `DELETE …/waypoints/{wpId}`, `POST …/waypoints/reorder`.
- `DELETE /missions/{id}` — `204`; removes the mission with its waypoints, targets, and
  incident events.

Mission status values: `DRAFT · UPLOADED · FLYING · COMPLETE`.

### 5.3 Live telemetry

#### `GET /vehicles/{id}/telemetry?from=&to=&limit=` — history (newest first)
```json
200 → {
  "vehicleId": "1", "count": 2,
  "samples": [
    { "id": "9002", "vehicleId": "1", "missionId": "42",
      "lat": 33.4406, "lon": -86.7892, "altM": 90, "headingDeg": 270,
      "groundSpeedMs": 8.0, "battPct": 96, "tsUtc": "2026-08-05T12:01:04.000Z" },
    { "id": "9001", "vehicleId": "1", "missionId": "42", "lat": 33.4405, "lon": -86.789,
      "altM": 45, "headingDeg": 268, "groundSpeedMs": 7.4, "battPct": 97,
      "tsUtc": "2026-08-05T12:01:02.000Z" }
  ]
}
```
`limit` defaults to 200, max 2000. `from`/`to` filter on `tsUtc` (ISO-8601). Poll this
(≈1 Hz) if you are not using the SignalR hub. The newest element is the drone's current
position.

#### `POST /telemetry` — ingest (aircraft → API)
The CAD does **not** normally call this — the drone/bridge does. Documented so you
understand the source. Body: `{ vehicleId, missionId?, lat, lon, altM, headingDeg,
groundSpeedMs, battPct, tsUtc? }` → `202 Accepted`, broadcasts `telemetry`.

### 5.4 Targets (on-scene detections)

#### `POST /targets` — record a detection
```json
// request
{ "missionId": "42", "type": "PERSON", "confidence": 0.91,
  "lat": 33.4406, "lon": -86.7892 }
// 202 →
{ "id": "77", "missionId": "42", "type": "PERSON", "confidence": 0.91,
  "lat": 33.4406, "lon": -86.7892, "tsUtc": "2026-08-05T12:01:10.000Z" }
```
`type` ∈ `PERSON · VEHICLE · OTHER`. Broadcasts `target` over SignalR **and** fires the
`target.detected` webhook (§7). This is the primary "the drone found something" signal
for the CAD — subscribe to the webhook or the hub to surface it to the dispatcher.

- `GET /missions/{missionId}/targets` — all detections for a mission.
- `GET /targets/{id}` · `PATCH /targets/{id}` · `DELETE /targets/{id}`.

### 5.5 Incident log (shared timeline)

Keeps the CAD incident record and the drone operation in one timeline.

#### `POST /missions/{missionId}/events` — append an entry
```json
// request
{ "kind": "ON_STATION", "message": "Drone overhead, camera on scene",
  "lat": 33.4405, "lon": -86.789 }
// 201 →
{ "id": "8", "missionId": "42", "kind": "ON_STATION",
  "message": "Drone overhead, camera on scene", "lat": 33.4405, "lon": -86.789,
  "tsUtc": "2026-08-05T12:01:20.000Z" }
```
`kind` is a free upper-cased string; suggested DFR vocabulary:
`DEPLOYED · EN_ROUTE · ON_STATION · TARGET_DETECTED · RTB · CLOSED`.
Broadcasts `incidentEvent` over SignalR. `message`, `lat`, `lon` optional.

> **Closing the call auto-completes the mission.** When you post an event whose `kind`
> is a CFS-closing kind — `CLOSED · CFS_CLOSED · CALL_CLOSED · CLEARED · CANCELLED ·
> CANCELED · COMPLETE` — the mission is also transitioned to `COMPLETE` and any drone
> that flew it returns to `IDLE`, exactly as if you had called
> `POST /missions/{id}/complete`. A `missionStatus` update is broadcast over SignalR;
> confirm via `GET /missions/{id}`. This lets a CAD close the incident with a single
> call: dispatch closing the call-for-service stands the drone down automatically. Any
> other `kind` only appends to the log and leaves the mission running.

- `GET /missions/{missionId}/events` — the full log (chronological).
- `GET /missions/{missionId}/events/{eventId}` · `PATCH` · `DELETE`.

#### `GET /missions/{missionId}/video` — live video descriptor
```json
200 → { "missionId": "42", "status": "SIMULATED", "streamKind": "SIMULATED",
        "note": "placeholder — real feed is onboard RTSP/SRT -> MediaMTX -> WebRTC" }
```
Placeholder in this release. In production this returns a WebRTC/whep descriptor for
the onboard camera feed.

### 5.6 Airspace / LAANC (optional pre-flight)

- `GET /airspace-auth/facility-map?lat=&lon=` → `{ lat, lon, gridCeilingFt, facility,
  provider }` — the UAS Facility Map ceiling for a point. Use it to sanity-check the
  requested altitude before launch.
- `POST /airspace-auth?submit=true` `{ lat, lon, requestedCeilingFt, radiusM?,
  missionId?, windowStartUtc?, windowEndUtc?, notes? }` → `201` authorization with
  `status` ∈ `AUTHORIZED · FURTHER_COORDINATION · DENIED` (evaluated against the
  facility map).
- Keyless it runs a clearly-labeled **mock** ("not a real FAA authorization"). Connect
  a real USS (Aloft/Airmap) account via `POST /airspace-auth/provider/connect`
  `{ apiKey, baseUrl }` to get live LAANC. `GET /airspace-auth/provider` reports which
  mode is active.

### 5.7 Geofence / operating-area configuration

Set the box + altitude ceiling that missions are validated against (§4). One config per
organization; changes take effect on the **next** mission. Editing requires an
`ORG_ADMIN` or `ENGINEER` (reads are open to any authenticated caller).

The operating area can be an axis-aligned **box** (`shape:"BBOX"`) or an arbitrary
**polygon** (`shape:"POLYGON"`) for irregular jurisdictions — waypoints are tested by
true point-in-polygon, not just the bounding box.

#### `GET /config/geofence` — the effective config
```json
200 → {
  "configured": false,          // false = using platform defaults
  "enabled": true,              // false = area check skipped (altitude ceiling still applies)
  "shape": "BBOX",              // "BBOX" | "POLYGON"
  "polygon": null,              // [[lon,lat],...] exterior ring when shape="POLYGON"
  "minLon": -86.83, "minLat": 33.40, "maxLon": -86.74, "maxLat": 33.49,  // box, or the polygon's bbox
  "maxAltM": 120, "maxAltCeilingM": 400,
  "label": "Vestavia Hills, AL (demo default)",
  "updatedUtc": null
}
```

#### `PUT /config/geofence` — set your operating area (box **or** polygon)
```json
// A) BOX  (enabled defaults true; maxAltM defaults 120, capped at 400)
{ "enabled": true,
  "minLon": -86.95, "minLat": 33.30, "maxLon": -86.55, "maxLat": 33.60,
  "maxAltM": 120, "label": "City of Homewood" }

// B) POLYGON — supply a ring of >= 3 [lon,lat] vertices instead of bounds.
//    The response bbox (minLon..maxLat) is computed from the ring.
{ "enabled": true,
  "polygon": [[-86.83,33.40], [-86.74,33.40], [-86.785,33.49]],
  "maxAltM": 120, "label": "County ABC jurisdiction" }
// 200 → the same shape as GET, with "configured": true (and shape/polygon reflecting your choice)
```
Validation: longitude ∈ [-180,180], latitude ∈ [-90,90], `0 < maxAltM ≤ 400`; a box needs
`minLon < maxLon` and `minLat < maxLat`; a polygon needs ≥ 3 distinct vertices — else
`400 BAD_BOUNDS` / `BAD_POLYGON` / `BAD_ALTITUDE`. Set `enabled:false` to keep an area on
file but skip the area check (the altitude ceiling is always enforced as a safety net).

#### `DELETE /config/geofence` — revert to the platform defaults
Removes your config; `GET` then reports `configured:false` again.

**Configure from the CLI** (no CORS — works against the hosted API directly):
```bash
# curl
TOKEN=$(curl -s https://dronefleet-api.azurewebsites.net/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@dronefleet.dev","password":"Passw0rd!"}' | jq -r .token)
curl -X PUT https://dronefleet-api.azurewebsites.net/api/v1/config/geofence \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"minLon":-86.95,"minLat":33.30,"maxLon":-86.55,"maxLat":33.60,"maxAltM":120,"label":"City of Homewood"}'
```
```powershell
# PowerShell
$b='https://dronefleet-api.azurewebsites.net/api/v1'
$H=@{Authorization='Bearer '+(Invoke-RestMethod "$b/auth/login" -Method Post -ContentType application/json -Body '{"email":"admin@dronefleet.dev","password":"Passw0rd!"}').token}
Invoke-RestMethod "$b/config/geofence" -Method Put -Headers $H -ContentType application/json -Body '{"minLon":-86.95,"minLat":33.30,"maxLon":-86.55,"maxLat":33.60,"maxAltM":120,"label":"City of Homewood"}'
```

**Configure visually:** `samples/geofence-config.html` — a self-contained map tool
(connect, draw your box, set altitude, Save). Because browsers enforce CORS, run it
against a local API or an allow-listed origin; for the hosted API use the CLI above.

### 5.8 The drone operator interface (host-app embed)

**You do not have to build a drone UI.** DroneFleet's drone operator interface — the same
ground-control view the DroneFleet dispatch map opens when a call-for-service or unit is
selected — is **hosted by the API at `GET {base}/console`**
(e.g. `https://dronefleet-api.azurewebsites.net/console`). Your host app (CAD / dispatch)
**opens that URL when it deploys a drone to an incident**. Because it is served from the
API, its calls are **same-origin — no CORS setup**. It shows a live GPS map with the drone
tracking, a camera-footprint ⇄ perspective video view, aircraft status
(alt/speed/heading/battery), scene target-ID, flight/payload controls, and the incident
overwatch — all driven by the endpoints in this document.

**How the host app launches it** — `window.open` (or an `<iframe>`) with the incident's
fields as URL parameters, authenticating with the installed **Client ID + Secret** (§2a):

```js
const base = "https://dronefleet-api.azurewebsites.net";
const p = new URLSearchParams({
  clientId: "dfc_…", clientSecret: "dfk_…",   // the CAD's installed credential (§2a)
  source: "CFS",                               // "CFS" (call-for-service) or "UNIT"
  caseNo: "25-00123", label: "Structure fire",
  addr:  "123 Main St", lat: "33.4405", lon: "-86.789",
  priority: "1", callsign: "DRONE-1",
  vehicleId: "1"                               // optional — else the first available fleet drone
});
window.open(`${base}/console?${p}`, "droneOps", "width=1440,height=900");
```

| Param | Meaning |
|---|---|
| `clientId` + `clientSecret` | the CAD's installed credential (§2a); `apiKey` also accepted |
| `source` | `CFS` or `UNIT` — what the drone is responding to |
| `caseNo · id · label · addr · priority · callsign` | incident metadata shown in the header |
| `lat` + `lon` | incident location (map centers here) |
| `vehicleId` | which fleet drone to track (optional — else auto-selects an available one) |
| `platform` | API base URL (optional — defaults to the origin the console is served from) |

The console connects with the credential, selects/locates the responding drone, and
streams live telemetry onto the map and HUD. To **deploy** the mission itself, the CAD
calls `POST /api/v1/missions` + `/start` (§5.2) before/while opening the console, or lets
dispatch drive it. Opened with no reachable platform it runs in a clearly-labeled
SIMULATED mode for testing.

> A standalone copy of the same page ships as [`drone-console.html`](drone-console.html)
> for offline reference; prefer the hosted `{base}/console` (same-origin, no CORS).

---

## 6. Real-time updates (SignalR)

Low-latency alternative to polling. Connect a **SignalR** client to `"<base>/hubs/fleet"`
(WebSockets; ASP.NET Core SignalR protocol). On connect you are placed in your tenant's
broadcast group automatically — pass your credential so the connection resolves to the
right tenant (SignalR JS client: `accessTokenFactory: () => "<bearer token>"`).

Server → client messages:

| Message | Payload | Fires when |
|---|---|---|
| `telemetry` | `{ vehicleId, missionId, lat, lon, altM, headingDeg, groundSpeedMs, battPct, tsUtc }` | a telemetry sample is ingested |
| `target` | `{ id, missionId, type, confidence, lat, lon, tsUtc }` | a detection is posted |
| `incidentEvent` | `{ id, missionId, kind, message, lat, lon, tsUtc }` | an incident-log entry is added |
| `missionStatus` | `{ missionId, status, activeWaypointSeq }` | a mission is started/completed |

If you cannot host a WebSocket client, poll the REST endpoints (§5.3, §5.4, §5.5)
and/or use webhooks (§7) — the data is identical.

---

## 7. Webhooks (push to the CAD)

Have the platform `POST` events to a URL your CAD exposes — the most robust way for a
server-side CAD to receive detections without holding a socket open.

#### Subscribe
```json
POST /api/v1/webhooks
{ "url": "https://cad.example.gov/hooks/dronefleet",
  "events": ["target.detected"],          // or ["*"] for all
  "secret": "optional-shared-secret" }
→ 201 { "id": "...", "url": "...", "events": ["target.detected"],
        "secret": "<hex signing key — shown once>" }
```
If you omit `secret`, a random 32-byte key is generated and returned (hex) once.

#### Delivery
Each event is delivered as an HTTP `POST` to your URL with headers:

```
X-DroneFleet-Event:     target.detected
X-DroneFleet-Signature: sha256=<hex HMAC-SHA256 of the raw request body, keyed by your secret>
Content-Type:           application/json
```
Verify by computing `HMAC_SHA256(secret, rawBody)` and comparing (constant-time) to the
header. Delivery is retried with back-off and dead-lettered after repeated failure.

#### DFR-relevant event
| Event | Payload | Source |
|---|---|---|
| `target.detected` | the target JSON (`{ id, missionId, type, confidence, lat, lon, tsUtc }`) | `POST /targets` |

Other platform events exist (`scan.processed`, `structure.unmapped`,
`deliverable.created`, `job.delivered`, `roofing.quote.created`, `utility.unpermitted`)
but are not part of the DFR flow. Note: incident-log entries are delivered over SignalR
(`incidentEvent`) and are readable via REST, but do **not** currently fire a webhook.

---

## 8. Errors

All errors use a single shape and an appropriate HTTP status:

```json
{ "error": { "code": "GEOFENCE_VIOLATION",
             "message": "Waypoint 1 (33.9, -86.8) is outside the allowed geofence […]." } }
```

Common codes:

| HTTP | code | Meaning / fix |
|---|---|---|
| 400 | `BAD_BODY` | missing/invalid field |
| 400 | `ALTITUDE_EXCEEDED` | waypoint `altM` > 120 |
| 400 | `GEOFENCE_VIOLATION` | waypoint outside the demo geofence (§4) |
| 400 | `BAD_TYPE` | target `type` not `PERSON`/`VEHICLE`/`OTHER` |
| 401 | `READ_ONLY` | anonymous write on the Azure demo — authenticate (§2) |
| 401 | `INVALID_API_KEY` | unknown/revoked/expired `X-Api-Key` |
| 401 | `NOT_AUTHENTICATED` | endpoint needs a signed-in caller |
| 403 | `FORBIDDEN` | authenticated but lacks the role (e.g. minting keys needs `ORG_ADMIN`) |
| 404 | `MISSION_NOT_FOUND` / `VEHICLE_NOT_FOUND` / `TARGET_NOT_FOUND` | bad id (or wrong tenant) |
| 409 | `MISSION_NOT_EDITABLE` | editing waypoints of a `FLYING`/`COMPLETE` mission |
| 409 | `PAYLOAD_MISMATCH` | assigned drone lacks a required payload |

---

## 9. Quick start (5 minutes)

1. Get a credential — log in and (as `admin@dronefleet.dev`) mint an API key, or just
   use a bearer token from `POST /auth/login` (§2).
2. `GET /vehicles` → note an `IDLE` drone's `id`.
3. `POST /missions` with one waypoint inside the geofence (§4) → note the mission `id`.
4. `POST /missions/{id}/start`.
5. Poll `GET /vehicles/{id}/telemetry` (or connect the hub) and watch the drone move.
6. `POST /missions/{id}/complete` when the call clears.

The [`samples/`](samples/) programs do exactly this, end to end, and print a readable
narrative — run one against the Azure base URL to see it work immediately.

---

## 10. CORS note (browser-based CAD only)

A **server-side** CAD is unaffected by CORS. A **browser-based** CAD calling the API
directly from a page needs its origin allow-listed on the API (`Cors:AllowedOrigins`)
— contact the platform operator to add it, or proxy the calls through your own backend
(the recommended pattern, since it also keeps the API key off the client).

---

*Generated for the DroneFleet platform. Endpoint shapes verified against the running
API on 2026-08-05.*
