Developers
CAD Webhook API
The published contract for pushing CAD dispatch data into Firedesk. It is written for the engineering team at a CAD vendor, paging provider, or dispatch center that wants their calls to land in a department's Firedesk account automatically.
If you can emit this shape, integrating needs no code on our side— onboarding is a configuration change. If your schema is fixed and can't be changed, see below — we'll write the translation layer.
Endpoints
| Purpose | Method | Path |
|---|---|---|
| Incident create / update / close | POST | /api/webhooks/cad/{vendor} |
| Responder GPS | POST | /api/webhooks/cad/{vendor}/positions |
{vendor} is the slug we assign you. Your token identifies you on its own, but the path is still enforced — posting a valid token to the wrong vendor's path returns 404, so a misconfiguration surfaces immediately instead of looking like success.
Authentication
Authorization: Bearer fd_whk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-api-key: <token> is also accepted, for vendors whose outbound-webhook screen only offers a custom header field.
If your console has no header field at all — some offer only a name and a URL — put the token in the path instead:
POST /api/webhooks/cad/{vendor}/{token}Same credential, same storage, no header required.
How you get one
You don't issue it yourself — the department does, from their Firedesk settings, or we do it with them during onboarding. Ask whoever owns the account, or contact us and we'll arrange it.
What you receive is the full URL and the token, with the vendor slug already filled in. It is displayed once and stored as a keyed hash, so it can never be read back — capture it when it's handed over.
It is a bearer credential: anyone holding it can file incidents into that department. Ask for it through whatever channel you would use for a password, not over email or chat.
More than one key can be active at a time, so rotation has no gap in it. A new key is issued and put in place on your side first; the old one keeps working until it is explicitly revoked. There is no window where deliveries fail, and no need to coordinate a cutover to the second.
Timestamps — the one strict rule
Every timestamp must carry an explicit UTC offset.
2026-08-03T14:22:05-05:00 or 2026-08-03T19:22:05Z. A value without one is rejected with 422.
This is stricter than most APIs, deliberately. A naive 2026-08-03T14:22:05 from a Central-time ECC is indistinguishable from UTC, and guessing wrong files a dispatch six hours off on a record a department may later be audited against — with nothing about it looking wrong afterward. A rejection you can see and fix is better than a silent error you can't.
Incident payload
{
"schema_version": "1.0",
"event": "incident.updated", // incident.created | incident.updated | incident.closed
"sent_at": "2026-08-03T14:22:05-05:00",
"incident": {
"external_id": "F23112801308", // REQUIRED - your stable id for this call
"cad_number": "SCC-2026-88213", // the ECC's own incident number, if different
"status": "active", // active | closed | cancelled
"dispatched_at": "2026-08-03T14:21:58-05:00", // REQUIRED
"closed_at": null,
"type": { "code": "MVC", "subcode": "INJ", "label": "Motor Vehicle Crash" },
"priority": "1",
"agency": "ST CLAIR ECC",
"narrative": "Two vehicles, one entrapment",
"location": {
"street": "426 S MAIN ST",
"unit": "APT 3B",
"place_name": "Lincoln Elementary",
"city": "O'Fallon",
"state": "IL",
"postal_code": "62269",
"cross_streets": ["N MAIN ST", "W 1ST ST"],
"latitude": 38.5921,
"longitude": -89.9112
},
"units": [
{ "id": "E1", "name": "Engine 1", "status": "onScene",
"status_at": "2026-08-03T14:29:11-05:00", "agency": "OFD" }
],
"responders": [
{ "id": "dev-77", "name": "A. Smith", "response": "Responding",
"at": "2026-08-03T14:22:30-05:00" }
],
"comments": [
{ "id": "c-9931", "body": "Caller reports smoke",
"at": "2026-08-03T14:23:40-05:00", "author": "DISP12" }
]
}
}Only two fields are required for the whole payload: incident.external_id and incident.dispatched_at. Everything else — including the units, responders and comments arrays — can be omitted, sent empty, or sent null.
In the tables below, a yes in the Req column means required within the object it belongs to, not that the object itself has to be sent. So a unit entry must carry an id and a status— but you never have to send a unit at all.
Envelope
| Field | Type | Req | Notes |
|---|---|---|---|
| schema_version | string | no | "1.0" |
| event | enum | no | incident.created | incident.updated | incident.closed |
| sent_at | timestamp | no | When you sent this delivery. Send it— it is how we discard deliveries that arrive out of order. |
incident
| Field | Type | Req | Notes |
|---|---|---|---|
| external_id | string ≤255 | yes | Your stable id for the call. The dedup key — the first delivery creates, later ones update. |
| cad_number | string ≤255 | no | The dispatch center's own incident number, when it differs from your id. It's the number a chief quotes back to dispatch. Defaults to external_id. |
| status | enum | no | active (default) | closed | cancelled |
| dispatched_at | timestamp | yes | Becomes the incident's alarm time. |
| closed_at | timestamp | no | Send with status: "closed". |
| type.code | string ≤255 | no | Mapped to a Firedesk incident type per department. Unmapped codes land as Unclassified with a prompt to map them — never rejected. |
| type.subcode | string ≤255 | no | Matched as (code, subcode) first, then (code, null). |
| type.label | string ≤255 | no | Human-readable, for display. |
| priority | string ≤100 | no | |
| agency | string ≤100 | no | Dispatch center identifier. |
| narrative | string | no |
incident.location
All optional — but send coordinates if you have nothing else. When city / state / postal code are missing and coordinates are present, Firedesk reverse-geocodes the rest.
| Field | Type | Req | Notes |
|---|---|---|---|
| street | string ≤255 | no | The street line only. Do not append zip or a jurisdiction abbreviation. |
| unit | string ≤100 | no | Apartment / suite. |
| place_name | string ≤255 | no | Used as the address when there is no street line. |
| city | string ≤100 | no | |
| state | string | no | Exactly 2 letters. |
| postal_code | string ≤10 | no | Its own field — never inside street. |
| cross_streets | string[] | no | Already split. Not one delimited string. |
| latitude | number | no | −90 to 90 |
| longitude | number | no | −180 to 180 |
incident.units — apparatus
A rig with a CAD status. Not people.
The array is optional — omit it, or send it empty or null, if you have no unit data. The columns below describe one entry, once you do send one.
| Field | Type | Req | Notes |
|---|---|---|---|
| id | string ≤255 | yes | Your unit identifier, mapped to a Firedesk apparatus. |
| status | string ≤100 | yes | See below. |
| status_at | timestamp | no | Defaults to sent_at. |
| name | string ≤255 | no | |
| agency | string ≤100 | no |
Send your own status vocabulary. dispatched, enroute, onScene, whatever your CAD uses. Each department maps those to run-report timestamps themselves; do not translate them for us. Send one entry per transition — a unit that went dispatched → enroute → onScene should produce all three, and re-sending them is harmless.
incident.responders — people
A person who declared a response, not a rig. The array is optional — omit it entirely if your system is apparatus-only. The columns below describe one entry.
| Field | Type | Req | Notes |
|---|---|---|---|
| id | string ≤255 | yes | |
| response | string ≤100 | yes | Your own label (Responding, Scene, Station 1). |
| at | timestamp | no | |
| name | string ≤255 | no |
incident.comments
The array is optional. The columns below describe one entry.
| Field | Type | Req | Notes |
|---|---|---|---|
| id | string ≤255 | yes | Must be stable across deliveries. |
| body | string | yes | |
| at | timestamp | no | |
| author | string ≤255 | no |
Send the full comment list on every delivery; we de-duplicate on id. A stable id matters: without one we would have to compare comment text, which silently collapses two identical lines a dispatcher legitimately typed twice.
Positions payload
Separate endpoint, on purpose. A GPS stream runs orders of magnitude more often than a dispatch update, and putting it on the incident endpoint would mean re-sending a whole incident envelope several times a minute per call.
{
"positions": [
{
"responder_id": "dev-77",
"latitude": 38.5921,
"longitude": -89.9112,
"at": "2026-08-03T14:25:00-05:00",
"accuracy_m": 12,
"name": "A. Smith",
"response": "Responding",
"incident_external_id": "F23112801308"
}
]
}Up to 500 per request. responder_id, latitude, longitude and at are required; the rest are optional. Send incident_external_id when you know it — Firedesk geofences arrivals against the incident scene and each station, and that arrival becomes the responder's on-scene time.
Responses
| Status | Meaning | What to do |
|---|---|---|
| 202 | Accepted and queued. | Nothing. |
| 401 | Missing or invalid token. | Check the Authorization header; rotate if the token was lost. |
| 404 | Valid token, wrong vendor path. | Fix the URL. |
| 422 | Body does not match this schema. | Read errors — it names the exact field path. |
A 422 looks like:
{
"message": "Payload does not match the FireDesk CAD schema",
"errors": {
"incident.dispatched_at": [
"The incident.dispatched_at field must carry an explicit UTC offset (e.g. 2026-08-03T14:22:05-05:00, or ...Z)."
]
}
}Every POST — accepted or rejected — is recorded with the body exactly as you sent it, so "we sent it and you didn't get it" can be settled without asking you for replays. Records are kept 7 days.
Example
curl -X POST https://api.firedesk.io/api/webhooks/cad/{vendor} \
-H "Authorization: Bearer fd_whk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"schema_version": "1.0",
"event": "incident.created",
"sent_at": "2026-08-03T14:22:05-05:00",
"incident": {
"external_id": "F23112801308",
"status": "active",
"dispatched_at": "2026-08-03T14:21:58-05:00",
"type": { "code": "MVC" },
"location": {
"street": "426 S MAIN ST",
"city": "Lebanon",
"state": "IL",
"postal_code": "62269",
"latitude": 38.5921,
"longitude": -89.9112
},
"units": [
{ "id": "E1", "status": "dispatched", "status_at": "2026-08-03T14:22:00-05:00" }
]
}
}'Delivery expectations
- Send an update whenever anything changes — a unit status, a comment, closure. Ingestion is idempotent, so re-sending an unchanged payload is a no-op.
- Order is not assumed. We compare
sent_atand discard a delivery older than the last one processed. - Never rejected for an unmapped code. Unknown unit ids and type codes create the incident anyway and surface in the UI for the department to map. Only a malformed payload is rejected.
- No retry endpoint. If we return non-2xx, retry with backoff; the body is stored either way.
If your schema differs
A vendor whose schema can't be changed gets a transformer — a small translation layer on our side that converts your body into this contract before validation. You keep sending exactly what you already send.
To build one we need a sample payload for each event you emit — a dispatch, a unit status change, and a close — plus whatever documentation you have for the fields. Real captured bodies are more useful than a spec, since they show the edge cases a spec usually omits.
Field names don't have to match and structure doesn't have to match. The two things we can't derive are a stable identifier for each call and an unambiguous dispatch time — if your payload carries those, the rest is a mapping exercise.
Ready to integrate?
Tell us which system you're sending from and we'll assign a vendor slug and walk a department through issuing your first token. Integrations that already match this contract are usually live the same day.
Contact us