# FireMic partner API — full developer documentation > Source pages: https://fire-mic.com/developers, https://fire-mic.com/developers/quickstart, https://fire-mic.com/developers/api, https://fire-mic.com/developers/embed > Machine-readable spec: https://fire-mic.com/developers/openapi.yaml > Base URL: https://app.fire-mic.com/v1 ## What you get - **One endpoint.** `POST https://app.fire-mic.com/v1/narrations` takes audio (or plain transcript text) plus the department's NERIS id, and returns the transcript, the incident as a **NERIS v1 payload**, the required fields it could not fill, and per-stage timings. Typical round trip for a one-minute narration is **4 to 8 seconds**. - **Nothing to host.** No SDK to vendor, no model to run, no NERIS credentials to hand us. Any HTTP client in any language. - **Your users, your records.** There are no FireMic accounts for your users. The credential is yours; the department is identified by its NERIS id; the firefighter never leaves your product. - **Test first, sign later.** A sandbox key works against the same production pipeline with tighter limits, so an engineer can integrate before a commercial agreement exists. ## What we never do - We never submit to NERIS on this surface. The API has no submit route, and the partner surface cannot reach our own NERIS client. You remain the submitter of record, with your validation, your state exports and your audit trail untouched. - We never see a NERIS username, password or client secret of yours. - We never guess silently. A required field the narration did not cover comes back in `gaps`, with the question we would ask the firefighter, instead of a made-up value. ## The flow, in four steps 1. **Mint a session.** Your backend (or, for a desktop client with no server tier, the client itself) calls `POST /v1/sessions` with your API key and the department's NERIS id. You get a token that lives 15 minutes and can only act for that department. Keys never ship inside client code. 2. **Record and send.** Your form records audio however you like and posts it, base64-encoded, to `POST /v1/narrations` with the session token. Or post text instead of audio if you already have a transcript. 3. **Fill the form.** The response carries `neris`, the same module shape NERIS accepts on its own incident endpoint. Map it to your fields once, at setup. Show `transcript` next to the fields so the firefighter can check what was heard, and surface `gaps` as the things still to fill. 4. **Tell us what happened.** When the firefighter saves, call `POST /v1/narrations/{id}/outcome` with whether the draft was kept and which fields they edited. This is optional, and it is how extraction gets better for your departments specifically. ## Try it in a minute Text mode needs no audio file. With a sandbox key: ```bash curl -s https://app.fire-mic.com/v1/narrations \ -H "Authorization: Bearer $FIREMIC_KEY" \ -H "Content-Type: application/json" \ -d '{ "department": "FD34007744", "external_ref": "INC-2026-001847", "transcript": "Engine 31 responded to 214 Maple Avenue for a reported structure fire, dispatched 14:21, on scene 14:27. Two story wood frame, light smoke showing second floor rear. Pulled an inch and three quarter through the front door, fire in a rear bedroom, room and contents, knocked down in about five minutes. Ladder 5 did a primary search, all clear. One occupant self evacuated, evaluated on scene, refused transport. Smoke detectors present and operated. Cause appears to be a space heater. Cleared at 15:45." }' ``` The [quickstart](/developers/quickstart) has the same call in C#, Swift, Kotlin and JavaScript, and the audio version. ## How fast it is Measured against production in September 2026, one-minute narrations: | Step | Typical time | |---|---| | Upload and transcription | 0.5 to 0.7 s per minute of audio | | Core extraction (incident type, location, times, units, narrative) | 2 to 6 s, scaling with narration length | | Module detail (fire, medical, hazardous situation), run in parallel | 0.5 to 1.5 s | | **Whole call, wall clock** | **4 to 8 s** | These are current measurements, not guarantees. Hold the connection open; set your client timeout to at least 120 seconds so a long or slow narration completes rather than orphaning a result. If your product needs it faster, the pipeline can be tuned further for a partner. Ask. While the call is in flight, `GET /v1/processing-messages` gives you the same rotating "while you wait" lines our own app shows, grouped by phase, if you want them. It is public and cacheable and entirely optional. ## Where it runs RedAlert-style desktop clients, native mobile apps and mounted tablets all work the same way: record on the device, one HTTPS call, fill the form. Anything with a webview can instead open our [hosted mic page](/developers/embed) with a session token and receive the finished draft as a single message, with host snippets for WebView2, WKWebView, Android WebView and the web. The page uses the same REST route documented here. ## What we keep, and for how long Every call is recorded: which partner and department, the transcript, the draft we returned, the gaps, timings, and later the outcome you report. Transcript, draft and outcome are retained so we can measure and improve extraction quality for your departments. Audio is stored separately and deleted after 30 days by default; a partner can set that shorter, including zero. Delete-on-request is honoured at the partner, department or narration level. No narration data is ever shared with another partner, and none of it is used to train anything outside FireMic. The full statement is in the [API reference](/developers/api#data-handling). ## Getting a key Keys are issued by a person, not a signup form. Use the contact buttons on the [About page](/about) with the subject "Partner API key" and tell us the product it is for. You get a sandbox key the same day; live keys follow the agreement. Keys can be partner-wide, naming the department on each request, or pinned to a single department so a key living inside one station's install can never act for another. ## Next - [Quickstart](/developers/quickstart): first narration in five languages, plus the session pattern. - [API reference](/developers/api): every endpoint, field, error code and limit. - [Embedding the hosted mic](/developers/embed): the webview route, with host code for every platform. - [OpenAPI 3.1 spec](/developers/openapi.yaml): generate a client, or load it into your API tool. - [llms-full.txt](/developers/llms-full.txt): all of this documentation as one plain-text file for your AI assistant. --- ## 0. Check the key ```bash curl -s https://app.fire-mic.com/v1/ping \ -H "Authorization: Bearer $FIREMIC_KEY" ``` ```json { "ok": true, "partner": { "slug": "your-company", "name": "Your Company" }, "mode": "test", "credential": { "via": "api_key", "scope": "partner", "department": null }, "limits": { "partner_narration_key": { "per_window": 60, "window_seconds": 3600 }, "partner_narration_partner_hour": { "per_window": 120, "window_seconds": 3600 }, "partner_narration_partner_day": { "per_window": 500, "window_seconds": 86400 } }, "server_time": "2026-09-02T10:51:30.003Z" } ``` `mode` is `test` or `live`; both hit the same pipeline. `limits` are the ceilings this credential currently has. ## 1. Find the department's NERIS id Every request names a department by its NERIS id, the `FD` plus eight digits identifier from the national NERIS directory. Your product almost certainly stores it already, because it is the id NERIS itself files under. To check one: ```bash curl -s https://app.fire-mic.com/v1/departments/FD34007744 \ -H "Authorization: Bearer $FIREMIC_KEY" ``` ```json { "object": "department", "neris_id": "FD34007744", "name": "Collingswood Fire Department", "city": "Collingswood", "state": "NJ" } ``` An id we do not know returns `404 DEPARTMENT_NOT_FOUND`. The free [department lookup](/tools/neris-department-lookup) searches the same directory by name. ## 2. First narration, text mode No audio needed. This is the fastest way to see the response shape. ```bash curl -s https://app.fire-mic.com/v1/narrations \ -H "Authorization: Bearer $FIREMIC_KEY" \ -H "Content-Type: application/json" \ -d '{ "department": "FD34007744", "external_ref": "INC-2026-001847", "transcript": "Engine 31 responded to 214 Maple Avenue for a reported structure fire, dispatched 14:21, on scene 14:27. Two story wood frame, light smoke showing second floor rear. Pulled an inch and three quarter through the front door, fire in a rear bedroom, room and contents, knocked down in about five minutes. Ladder 5 did a primary search, all clear. One occupant self evacuated, evaluated on scene, refused transport. Smoke detectors present and operated. Cause appears to be a space heater. Cleared at 15:45." }' ``` You get back a narration object. The parts you will use first: ```json { "id": "nar_UFaCShS95qMXWZuVIG5NmT1y", "status": "complete", "transcript": "Engine 31 responded to 214 Maple Avenue …", "neris": { "incident_types": [{ "type": "FIRE||STRUCTURE_FIRE||ROOM_AND_CONTENTS_FIRE", "primary": true }], "base": { "department_neris_id": "FD34007744", "location": { "…": "…" }, "outcome_narrative": "…" }, "dispatch": { "…": "…" }, "fire_detail": { "…": "…" } }, "gaps": [ { "field": "incident_number", "label": "Incident Number", "question": "What is the incident number?" } ], "timings": { "transcribe_ms": null, "stage1_ms": 3652, "stage2_ms": 474, "total_ms": 4730 } } ``` `neris` is the payload shape NERIS accepts on its own incident endpoint, so the field names are the ones your NERIS integration already knows. The [API reference](/developers/api#the-narration-object) documents every key. ## 3. Audio mode Post the recording base64-encoded. Any common container works: webm/opus, ogg/opus, mp4/m4a/aac, wav, mp3, flac. Mono at 32 to 64 kbps is plenty for speech and keeps a minute of audio under half a megabyte. ```bash AUDIO_B64=$(base64 < narration.m4a | tr -d '\n') curl -s https://app.fire-mic.com/v1/narrations \ -H "Authorization: Bearer $FIREMIC_KEY" \ -H "Content-Type: application/json" \ -d "{ \"department\": \"FD34007744\", \"external_ref\": \"INC-2026-001847\", \"audio\": { \"data\": \"$AUDIO_B64\", \"mime\": \"audio/mp4\", \"duration_seconds\": 59.2 } }" ``` `duration_seconds` is optional but recommended; it is stored with the narration and lets us refuse a mistaken multi-hour upload before decoding it. Limits: 25 MB and 15 minutes per request. ## 4. Sessions: keys stay on the server, tokens go to the client Never put an API key in a browser, a webview, a mobile app or a desktop installer. Mint a short-lived session for the incident instead, from wherever you can keep a secret: ```bash curl -s https://app.fire-mic.com/v1/sessions \ -H "Authorization: Bearer $FIREMIC_KEY" \ -H "Content-Type: application/json" \ -d '{ "department": "FD34007744", "external_ref": "INC-2026-001847", "ttl_seconds": 900 }' ``` ```json { "object": "session", "token": "eyJhbGciOiJIUzI1NiJ9…", "token_type": "Bearer", "expires_at": "2026-09-02T11:09:55.000Z", "mode": "test", "department": { "neris_id": "FD34007744", "name": "Collingswood Fire Department", "city": "Collingswood", "state": "NJ" }, "external_ref": "INC-2026-001847" } ``` The client then calls `POST /v1/narrations` with `Authorization: Bearer ` and no `department` field; the token already carries it. A session can only act for its department, cannot mint further sessions, and dies with the key it came from. CORS is open on `/v1`, so a browser or webview can call it directly. If your desktop client has no server tier at all, use a **department-scoped key** in that install: it is pinned to one department at issue time, so a leaked key from one station cannot act for another. ## 5. Report the outcome When the firefighter saves the report, tell us whether they kept the draft. Optional, one call, and the single most useful thing you can send us. ```bash curl -s https://app.fire-mic.com/v1/narrations/nar_UFaCShS95qMXWZuVIG5NmT1y/outcome \ -H "Authorization: Bearer $FIREMIC_KEY" \ -H "Content-Type: application/json" \ -d '{ "accepted": true, "edited_fields": ["base.location_use.level1", "dispatch.time_incident_clear"], "submitted_to_neris": true, "neris_incident_id": "…", "review_seconds": 48 }' ``` --- ## The same first call in other languages Each snippet posts a transcript with an API key and prints the primary incident type and the gaps. Swap the transcript for an `audio` object as in step 3 to send a recording, and swap the key for a session token on any client device. ### C# (.NET 8) ```csharp using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; var http = new HttpClient { BaseAddress = new Uri("https://app.fire-mic.com/v1/"), Timeout = TimeSpan.FromSeconds(120) }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("FIREMIC_KEY")); var body = new { department = "FD34007744", external_ref = "INC-2026-001847", transcript = "Engine 31 responded to 214 Maple Avenue for a reported structure fire …", // audio = new { data = Convert.ToBase64String(File.ReadAllBytes("narration.m4a")), mime = "audio/mp4" }, }; using var res = await http.PostAsJsonAsync("narrations", body); using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync()); if (!res.IsSuccessStatusCode) { var err = doc.RootElement.GetProperty("error"); Console.WriteLine($"{(int)res.StatusCode} {err.GetProperty("code")}: {err.GetProperty("message")}"); return; } var root = doc.RootElement; Console.WriteLine(root.GetProperty("neris").GetProperty("incident_types")[0].GetProperty("type").GetString()); foreach (var gap in root.GetProperty("gaps").EnumerateArray()) Console.WriteLine($"still needed: {gap.GetProperty("label")} — {gap.GetProperty("question")}"); ``` WebView2 hosts: mint the session on the desktop side, open the hosted mic page with the token, and handle its one `WebMessageReceived` event; that page is documented with the embed guide. ### Swift (iOS 15+) ```swift import Foundation struct Narration: Decodable { struct Gap: Decodable { let field: String; let label: String; let question: String } let id: String let transcript: String let neris: [String: AnyCodable] // map to your model, or decode the modules you use let gaps: [Gap] } func narrate(token: String, audioURL: URL) async throws -> Narration { var req = URLRequest(url: URL(string: "https://app.fire-mic.com/v1/narrations")!) req.httpMethod = "POST" req.timeoutInterval = 120 req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") let audio = try Data(contentsOf: audioURL).base64EncodedString() // department omitted: a session token already carries it. req.httpBody = try JSONSerialization.data(withJSONObject: [ "external_ref": "INC-2026-001847", "audio": ["data": audio, "mime": "audio/mp4"], ]) let (data, response) = try await URLSession.shared.data(for: req) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw NSError(domain: "FireMic", code: 1, userInfo: [NSLocalizedDescriptionKey: String(decoding: data, as: UTF8.self)]) } return try JSONDecoder().decode(Narration.self, from: data) } ``` Record with `AVAudioRecorder` into `.m4a` (AAC, mono, 32 kbps) and send the file when the firefighter taps stop. ### Kotlin (Android, OkHttp) ```kotlin import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.io.File import java.util.Base64 import java.util.concurrent.TimeUnit val client = OkHttpClient.Builder().callTimeout(120, TimeUnit.SECONDS).build() fun narrate(token: String, audio: File, externalRef: String): JSONObject { val body = JSONObject() .put("external_ref", externalRef) .put("audio", JSONObject() .put("data", Base64.getEncoder().encodeToString(audio.readBytes())) .put("mime", "audio/mp4")) val request = Request.Builder() .url("https://app.fire-mic.com/v1/narrations") .header("Authorization", "Bearer $token") .post(body.toString().toRequestBody("application/json".toMediaType())) .build() client.newCall(request).execute().use { res -> val json = JSONObject(res.body!!.string()) if (!res.isSuccessful) { val err = json.getJSONObject("error") throw IllegalStateException("${res.code} ${err.getString("code")}: ${err.getString("message")}") } return json // json.getJSONObject("neris"), json.getJSONArray("gaps"), json.getString("transcript") } } ``` Record with `MediaRecorder` (`AAC` in an `MPEG_4` container, mono, 32 kbps) and call `narrate` from a coroutine on `Dispatchers.IO`. ### JavaScript (browser or webview, session token) ```js // The page got a session token from your backend; the key never reaches the browser. async function narrate(sessionToken, audioBlob, externalRef) { const data = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result.split(',')[1]); // strip the data: prefix r.onerror = reject; r.readAsDataURL(audioBlob); }); const res = await fetch('https://app.fire-mic.com/v1/narrations', { method: 'POST', headers: { Authorization: `Bearer ${sessionToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ external_ref: externalRef, audio: { data, mime: audioBlob.type || 'audio/webm' } }), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error.code}: ${json.error.message}`); return json; // json.neris, json.gaps, json.transcript } // Recording: MediaRecorder with { mimeType: 'audio/webm;codecs=opus', audioBitsPerSecond: 48000 } ``` ### Node.js (server side, API key) ```js const res = await fetch('https://app.fire-mic.com/v1/narrations', { method: 'POST', headers: { Authorization: `Bearer ${process.env.FIREMIC_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ department: 'FD34007744', external_ref: 'INC-2026-001847', transcript: 'Engine 31 responded to 214 Maple Avenue for a reported structure fire …', }), signal: AbortSignal.timeout(120_000), }); const narration = await res.json(); if (!res.ok) throw new Error(`${res.status} ${narration.error.code}: ${narration.error.message}`); console.log(narration.neris.incident_types[0].type, narration.gaps.map(g => g.label)); ``` ## What to build next 1. **Field map, once.** Walk `neris` module by module against your incident model. It is the NERIS payload shape, so most keys map one to one. We will sit with your engineer for an hour to do it. 2. **Show the transcript.** Next to the filled fields, always. The firefighter checks what was heard against what was said. 3. **Show the gaps.** Each gap has a question written for a firefighter. Three empty fields with a question each beats a wall of red. 4. **Send outcomes.** Accepted or not, and which fields were edited. That is the loop that makes the drafts better for your departments. --- ## Conventions - **Base URL** `https://app.fire-mic.com/v1`. HTTPS only. - **Requests** are JSON (`Content-Type: application/json`). Audio travels base64-encoded inside the JSON body. - **Responses** are JSON. Success bodies carry an `object` field naming the shape. Every error, from every route, is: ```json { "error": { "code": "DEPARTMENT_NOT_FOUND", "message": "No department with NERIS id FD00000000. …", "details": { "…": "optional" } } } ``` - **Authentication** is `Authorization: Bearer `, where the credential is an API key (`fm_live_…` or `fm_test_…`) or a session token from `POST /v1/sessions`. - **CORS** is open on every route, so browsers and webviews can call `/v1` directly with a session token. Preflight `OPTIONS` returns 204. - **Every response** carries an `X-Request-Id` header. Quote it when you report a problem. - **Caching**: responses are `Cache-Control: no-store`, except `GET /v1/processing-messages`, which is public and cacheable for an hour. - **Timeouts**: `POST /v1/narrations` is synchronous and typically returns in 4 to 8 seconds; set your client timeout to at least 120 seconds. ## Authentication ### API keys Issued by FireMic, shown once, stored hashed. Two properties are fixed at issue time: | Property | Values | Meaning | |---|---|---| | `mode` | `test`, `live` | Same pipeline, same responses. Test keys have tighter rate limits and their narrations are flagged as test data. | | `scope` | `partner`, `department` | A partner-scoped key names the department on each request. A department-scoped key is pinned to one department and rejects any other with `403 DEPARTMENT_SCOPE_MISMATCH`. Use department-scoped keys anywhere a key must live on a device, such as a desktop installer with no server tier. | Keys are for servers. Do not embed a partner-scoped key in a browser, mobile app or desktop client; mint sessions instead. Revoking a key takes effect within one request and also invalidates every session minted from it. ### Session tokens `POST /v1/sessions` exchanges an API key for a signed token that can only act for one department, for at most an hour. This is the credential for anything running on a user's device. A session token cannot create sessions, cannot read other departments, and is refused everywhere with `401 SESSION_EXPIRED` once past its expiry. ### Failures | Status | Code | When | |---|---|---| | 401 | `MISSING_CREDENTIAL` | No `Authorization: Bearer` header. | | 401 | `INVALID_CREDENTIAL` | Unknown key, malformed token, or a session token used where a key is required. | | 401 | `KEY_REVOKED` | The key (or the key behind this session) was revoked. | | 401 | `SESSION_EXPIRED` | The session token is past `expires_at`. Mint a new one. | | 403 | `PARTNER_SUSPENDED` | The partner account is suspended. | | 503 | `PARTNER_API_UNAVAILABLE` | Our side cannot verify credentials right now. Retry with backoff. | ## Rate limits Limits are per hour unless stated, counted on the expensive route (`POST /v1/narrations`) across five dimensions at once. A refusal is `429 RATE_LIMITED` with a `Retry-After` header and `details.retry_after_seconds`. | Dimension | Test key | Live key | |---|---|---| | Per key | 60 | 600 | | Per partner (all keys) | 120 | 3,000 | | Per partner per day | 500 | 20,000 | | Per department | 200 | 400 | | Per source IP | 300 | 600 | The cheap routes (`sessions`, reads, `outcome`) share one ceiling of 1,200 per key per hour. Per-partner ceilings can be raised for a live partner; `GET /v1/ping` reports the ones your credential currently has. If our limiter itself is unavailable, test keys are refused (`503`) and live keys are allowed through: a limiter outage must never block a firefighter mid-incident. ## Input limits | Input | Limit | |---|---| | Audio, decoded | 25 MB | | Audio, duration | 15 minutes (`AUDIO_TOO_LONG` when `duration_seconds` says more) | | Audio, types | `audio/webm` (opus), `audio/ogg` (opus), `audio/mp4`, `audio/m4a`, `audio/aac`, `audio/wav`, `audio/mpeg`, `audio/mp3`, `audio/flac` | | Transcript | 50,000 characters | | `external_ref` | 200 characters | | `metadata` | 4 KB of JSON | | Outcome body | 256 KB of JSON | ## Endpoints ### GET /v1/ping Confirms a credential and reports what it can do. Accepts a key or a session. ```json { "ok": true, "partner": { "slug": "alpine", "name": "Alpine Software" }, "mode": "test", "credential": { "via": "api_key", "scope": "partner", "department": null, "department_name": null }, "limits": { "partner_narration_key": { "per_window": 60, "window_seconds": 3600 }, "partner_narration_partner_hour": { "per_window": 120, "window_seconds": 3600 }, "partner_narration_partner_day": { "per_window": 500, "window_seconds": 86400 } }, "server_time": "2026-09-02T10:51:30.003Z" } ``` `credential.department` is the NERIS id a session token is scoped to (with its `department_name`), or `null` for a key. ### POST /v1/sessions API key only. Mints a department-scoped session token for a client device. **Request** | Field | Type | Required | Notes | |---|---|---|---| | `department` | string | for partner-scoped keys | NERIS department id, `FD` + 8 digits. Omit for a department-scoped key. | | `external_ref` | string | no | Your incident or record reference, up to 200 characters. Carried on every narration made with the session. | | `ttl_seconds` | number | no | 60 to 3600. Default 900. | **Response** `201` ```json { "object": "session", "token": "eyJhbGciOiJIUzI1NiJ9…", "token_type": "Bearer", "expires_at": "2026-09-02T11:04:55.000Z", "mode": "test", "department": { "neris_id": "FD34007744", "name": "Collingswood Fire Department", "city": "Collingswood", "state": "NJ" }, "external_ref": "RA-SESSION-1" } ``` Errors: `400 DEPARTMENT_REQUIRED`, `400 DEPARTMENT_INVALID`, `400 INVALID_TTL`, `400 INVALID_EXTERNAL_REF`, `403 DEPARTMENT_SCOPE_MISMATCH`, `404 DEPARTMENT_NOT_FOUND`, and `401 INVALID_CREDENTIAL` when a session token is presented instead of a key. ### POST /v1/narrations The call. Accepts a key or a session. Synchronous: the response is the finished draft. **Request** | Field | Type | Required | Notes | |---|---|---|---| | `department` | string | see notes | NERIS department id. Required with a partner-scoped key. Optional with a session or a department-scoped key, and if present it must match. | | `audio` | object | one of `audio` / `transcript` | `{ "data": "", "mime": "audio/mp4", "duration_seconds": 59.2 }`. A `data:` URL prefix is tolerated. `duration_seconds` is optional. | | `transcript` | string | one of `audio` / `transcript` | Already-transcribed narration, or typed text. Skips transcription. | | `external_ref` | string | no | Up to 200 characters. Defaults to the session's `external_ref` if any. | | `metadata` | object | no | Anything you want stored with the narration and echoed back, up to 4 KB. Not interpreted. | **Response** `200`: a [narration object](#the-narration-object) with `status: "complete"`. **Errors** | Status | Code | Meaning | |---|---|---| | 400 | `INVALID_BODY` | Body is not a JSON object. | | 400 | `INPUT_REQUIRED` | Neither or both of `audio` and `transcript`. | | 400 | `INVALID_AUDIO`, `UNSUPPORTED_AUDIO_TYPE`, `AUDIO_TOO_LARGE`, `AUDIO_TOO_LONG` | See input limits. | | 400 | `INVALID_TRANSCRIPT`, `TRANSCRIPT_TOO_LONG` | Empty, non-string, or over 50,000 characters. | | 400 | `INVALID_EXTERNAL_REF`, `INVALID_METADATA` | Over the limit or the wrong type. | | 400 | `DEPARTMENT_REQUIRED`, `DEPARTMENT_INVALID` | Missing or malformed NERIS id with a partner-scoped key. | | 403 | `DEPARTMENT_SCOPE_MISMATCH` | The body names a department the credential cannot act for. | | 404 | `DEPARTMENT_NOT_FOUND` | Not in the national directory. | | 422 | `EMPTY_TRANSCRIPT` | The audio produced no usable speech (a room recording, a muted mic). `details.id` and `details.transcript` are set; nothing was extracted. | | 429 | `RATE_LIMITED` | See rate limits. | | 502 | `TRANSCRIPTION_FAILED`, `EXTRACTION_FAILED` | An upstream stage failed. `details.id` is set; for `EXTRACTION_FAILED`, `details.transcript` carries the transcript so you can still show it. Retry once after a few seconds. | | 503 | `PARTNER_API_UNAVAILABLE` | Retry with backoff. | The call is not idempotent: a retry creates a second narration. Send `external_ref` so you can reconcile duplicates on your side, and do not retry a request that returned 200. ### GET /v1/narrations/{id} Re-reads a narration. Accepts a key or a session; only the partner that created the narration can read it. Returns the [narration object](#the-narration-object), including `outcome` once reported. `404 NOT_FOUND` for an unknown id or another partner's narration. ### POST /v1/narrations/{id}/outcome Reports what the firefighter did with the draft. Optional, idempotent (the latest report wins), and the single most useful thing a partner can send. | Field | Type | Required | Notes | |---|---|---|---| | `accepted` | boolean | yes | Did the firefighter keep the draft as the basis of the report? | | `edited_fields` | string[] | no | Paths the firefighter changed before saving, up to 200. Use your own field names or the `neris` paths; both are useful. | | `neris` | object | no | The final NERIS payload as submitted, if you want us to learn from the corrected values. Up to 256 KB. | | `submitted_to_neris` | boolean | no | Whether the report was filed. | | `neris_incident_id` | string | no | The NERIS incident id after filing, up to 120 characters. | | `review_seconds` | number | no | Time from draft to save, 0 to 86,400. | | `notes` | string | no | Free text, up to 2,000 characters. | **Response** `200` `{ "ok": true, "id": "nar_…", "outcome_at": "2026-09-02T10:55:28.397Z" }`. Errors: `400 INVALID_OUTCOME`, `400 OUTCOME_TOO_LARGE`, `404 NOT_FOUND`. ### GET /v1/departments/{neris_id} Directory check. Accepts a key or a session. ```json { "object": "department", "neris_id": "FD34007744", "name": "Collingswood Fire Department", "city": "Collingswood", "state": "NJ" } ``` Errors: `400 DEPARTMENT_INVALID`, `404 DEPARTMENT_NOT_FOUND`. ### GET /v1/processing-messages Public, no authentication, cacheable. The rotating lines FireMic's own app shows while a narration is processing, grouped by the phase they describe. Show one at a time, in order, from a random starting point, changing every `suggested_interval_ms`. Entirely optional. ```json { "object": "processing_messages", "suggested_interval_ms": 2200, "phases": { "transcribing": ["Transcribing your narration…", "Turning speech into text…", "…"], "extracting_core": ["Reading the incident…", "Reading the callout…", "…"], "extracting_modules": ["…"] }, "note": "Show one line at a time, in order, from a random start. Typical total wait: 6–8 s for a one-minute narration." } ``` ## The narration object Returned by `POST /v1/narrations` and `GET /v1/narrations/{id}`. This is a real production response for a 59-second recording, lightly trimmed: ```json { "id": "nar_UFaCShS95qMXWZuVIG5NmT1y", "object": "narration", "status": "complete", "mode": "test", "department": { "neris_id": "FD36103862", "name": "Sayville Fire Department", "city": "Sayville", "state": "NY" }, "external_ref": "RA-2026-001848", "input": { "kind": "audio", "mime": "audio/mp4", "audio_seconds": 59.2 }, "transcript": "Engine 31 responding to 214 Maple Avenue for a reported structure fire. Dispatched at 1421. Enroute at 1423. On scene at 1427. On arrival we had a two-story wood frame single family dwelling with light smoke showing from the secon \u2026", "neris": { "base": { "department_neris_id": "FD36103862", "incident_number": "AUTO-1788346494711", "location": { "number": 214, "complete_number": "214", "street": "Maple", "street_postfix": "AVENUE", "incorporated_municipality": "Sayville", "state": "NY" }, "location_use": { "use_type": "RESIDENTIAL||DETATCHED_SINGLE_FAMILY_DWELLING", "in_use": { "in_use": true } }, "people_present": true, "animals_rescued": 0, "outcome_narrative": "Engine 31 responded to 214 Maple Avenue for a reported structure fire. Dispatched at 14:21, enroute at 14:23, and arrived on scene at 14:27. Upon arrival, crews \u2026", "displacement_count": 1, "displacement_causes": [ "FIRE" ] }, "incident_types": [ { "type": "FIRE||STRUCTURE_FIRE||ROOM_AND_CONTENTS_FIRE", "primary": true } ], "dispatch": { "incident_number": "AUTO-1788346494711", "incident_clear": "2026-09-02T15:45:00Z", "call_arrival": "2026-09-02T14:21:00Z", "call_answered": "2026-09-02T14:21:00Z", "call_create": "2026-09-02T14:21:00Z", "location": { "number": 214, "complete_number": "214", "street": "Maple", "street_postfix": "AVENUE", "incorporated_municipality": "Sayville", "state": "NY" }, "unit_responses": [ { "reported_unit_id": "Engine 31", "staffing": 4, "dispatch": "2026-09-02T14:21:00Z", "enroute_to_scene": "2026-09-02T14:23:00Z", "on_scene": "2026-09-02T14:27:00Z", "unit_clear": "2026-09-02T15:45:00Z", "response_mode": "EMERGENT" }, { "reported_unit_id": "Ladder 5", "staffing": 3, "on_scene": "2026-09-02T14:30:00Z", "unit_clear": "2026-09-02T15:45:00Z", "response_mode": "EMERGENT" } ] }, "tactic_timestamps": { "command_established": "2026-09-02T14:27:00Z", "completed_sizeup": "2026-09-02T14:27:00Z", "primary_search_begin": "2026-09-02T14:30:00Z", "primary_search_complete": "2026-09-02T14:35:00Z", "water_on_fire": "2026-09-02T14:30:00Z", "fire_under_control": "2026-09-02T14:35:00Z", "fire_knocked_down": "2026-09-02T14:35:00Z" }, "nonfd_aids": [ "UTILITIES_PUBLIC_WORKS" ], "smoke_alarm": { "presence": { "type": "PRESENT", "working": true } }, "fire_alarm": { "presence": { "type": "NOT_PRESENT" } }, "other_alarm": { "presence": { "type": "NOT_PRESENT" } }, "fire_suppression": { "presence": { "type": "NOT_PRESENT" } }, "fire_detail": { "location_detail": { "type": "STRUCTURE", "floor_of_origin": 2, "arrival_condition": "SMOKE_SHOWING", "damage_type": "MINOR_DAMAGE", "room_of_origin_type": "BEDROOM", "cause": "HEAT_FROM_ANOTHER_OBJECT" }, "water_supply": "HYDRANT_LESS_500", "investigation_needed": "YES", "investigation_types": [ "NONE" ], "suppression_appliances": [ "SMALL_DIAMETER_FIRE_HOSE" ] } }, "firemic_report": { "\u2026": "the FireMic-native report the NERIS payload was built from; see \"firemic_report\" below" }, "gaps": [ { "field": "incident_number", "label": "Incident Number", "question": "What was the incident number assigned by dispatch?" } ], "modules": { "activated": [ "fire_detail" ], "failed": [] }, "timings": { "transcribe_ms": 604, "stage1_ms": 3652, "stage2_ms": 474, "total_ms": 4730 }, "model": { "extraction": "firemic-extract-2026.09", "transcription": "firemic-asr-2026.09" }, "metadata": null, "error": null, "outcome": null, "outcome_at": null, "created_at": "2026-09-02T10:54:41.120Z", "completed_at": "2026-09-02T10:54:45.850Z" } ``` | Field | Type | Meaning | |---|---|---| | `id` | string | `nar_` + 24 characters. Opaque. | | `object` | `"narration"` | | | `status` | `complete`, `failed`, `processing` | A `POST` only ever returns `complete`; `failed` rows exist for reads after a 422/502. | | `mode` | `test`, `live` | The key's mode. | | `department` | object | `neris_id`, `name`, `city`, `state` of the department the narration is for. | | `external_ref` | string or null | Yours, echoed. | | `input` | object | `kind` (`audio` or `text`), `mime`, `audio_seconds` as sent. | | `transcript` | string | What was heard, after our fire-service vocabulary. Show it to the firefighter. | | `neris` | object | The incident as a **NERIS v1 incident payload**: the same module keys and enumerated values NERIS accepts on its own incident endpoint. Empty modules are omitted. Incident types are `L1||L2||L3` strings, exactly as NERIS encodes them. Field semantics are NERIS's, documented in the [NERIS data dictionary](https://neris.fsri.org/data-dictionary); our plain-language [field reference](/tools/neris-field-reference) and [incident type lookup](/tools/neris-incident-types) cover the same tables. | | `firemic_report` | object | The FireMic-native report the payload was built from. Superset of `neris`: it also carries fields NERIS has no slot for (smoke-detector notes, a few special-study answers) and keeps empty modules with their defaults, which is convenient for rendering a form. Treat it as extended and additive: keys may be added without notice. | | `gaps` | array | Required fields the narration did not cover. Each has a stable `field` id, a human `label`, and the `question` we would ask the firefighter. A clarification endpoint that takes the answer and returns field updates is on the roadmap; today, the questions are yours to ask in your form. | | `modules` | object | `activated`: which detail modules the core extraction decided applied (`fire_detail`, `medical_details`, `hazsit_detail`, `exposures`). `failed`: any of those whose detail call failed; the module is present but empty. | | `timings` | object | Milliseconds: `transcribe_ms` (null for text input), `stage1_ms`, `stage2_ms`, `total_ms`. | | `model` | object | Opaque version labels for the transcription and extraction stages. Quote them in a bug report. They change when the extraction changes materially. | | `metadata` | object or null | Yours, echoed. | | `error` | object or null | `{ "code": … }` on a failed narration. | | `outcome`, `outcome_at` | object, string | What you reported, if anything. | | `created_at`, `completed_at` | string | ISO 8601 UTC. | ### What extraction does and does not do - Times spoken as clock times ("dispatched at 14:21") become full ISO timestamps on the incident date. Times not spoken are estimated only for tactic milestones the firefighter described happening, in sequence after arrival; they are never invented for events that were not mentioned. - The department's own city and state fill in when the narration omits them, and a mis-heard spelling of the department's city is corrected to the directory spelling. A different town, such as mutual aid to the next department over, is left as spoken. - A call cancelled before arrival is coded `NOEMERG||CANCELLED` regardless of the nature it was dispatched as; the dispatched nature goes in the narrative. - Nothing carries a confidence score. A field is either filled from what was said or absent and listed in `gaps`. Show the transcript; the firefighter is the confidence score. ## Data handling What a narration stores: partner, key id, mode, department, your `external_ref` and `metadata`, the input kind, the transcript, the `neris` draft and `firemic_report` we returned, `gaps`, `modules`, timings, model versions, a hash of the caller's IP, and the outcome you report. - **Audio** is stored in a private bucket only we can read and deleted after the partner's retention window, 30 days by default, by a daily job. A partner can set the window shorter, including zero, in which case audio is never written at all. - **Transcript, draft and outcome** are retained. They are what lets us measure extraction quality per department and improve it, and they are the basis of any accuracy figures we report to you. - **Isolation**: a partner reads only its own narrations. Narration data is never shared with another partner and is not used to train anything outside FireMic. - **Deletion on request** at the partner, department or narration level. - **NERIS credentials**: never requested, never held. This API cannot submit to NERIS. - **Test mode** rows are flagged as test data and excluded from any quality reporting. ## Versioning - The path carries the major version. Additive changes (new fields, new error codes, new endpoints) ship on `/v1` without notice; read responses tolerantly. Anything that removes or changes the meaning of a field ships as `/v2` with the old version kept alive. - `firemic_report` is explicitly extended and additive. - `model.extraction` and `model.transcription` change when the underlying extraction changes materially, so a partner can correlate a change in drafts with a change on our side. ## Changelog - **2026-09** v1 published: sessions, narrations (audio and text), outcome reporting, department lookup, processing messages. --- ## What it is `https://app.fire-mic.com/widget/v1` is a self-contained page: a mic button, a level meter, pause and stop, a "while you wait" screen, and a summary of the draft. It calls `POST /v1/narrations` itself with the session token you give it, then hands the finished [narration object](/developers/api#the-narration-object) to whatever is hosting it. There is no FireMic login and no app shell around it. It runs anywhere a modern webview runs: **WebView2** on Windows desktops, **WKWebView** on iPhone and iPad, **Android WebView** on phones and tablets, and an **iframe** on a web page. Each needs two things from the host: microphone permission for the page, and a listener for the draft. ## The contract ### In: the session token Mint a session on your side with `POST /v1/sessions` (see the [quickstart](/developers/quickstart#4-sessions-keys-stay-on-the-server-tokens-go-to-the-client)), then open ``` https://app.fire-mic.com/widget/v1#token=&ref= ``` The token rides in the URL **fragment**, which never leaves the device: not to our servers, not to a proxy, not into a log. `ref` is optional and becomes the narration's `external_ref`. Add `&autostart=1` to start recording as soon as the mic is granted. If your host cannot set a fragment, `?token=` in the query string is accepted, and so is calling the page at runtime with `window.FireMicWidget.init({ token, externalRef })` after it loads. The page refuses an API key outright: a key in a client is the leak sessions exist to prevent. The page also exposes `window.FireMicWidget.start()`, `.stop()`, `.cancel()`, and `.submitAudio(blob, mimeType, durationSeconds)` for hosts that record natively but still want the page's upload, waiting screen and hand-off. ### Out: messages The page posts plain objects to every host bridge it can find. Each has a `type` and a `version`: | `type` | Fields | When | |---|---|---| | `firemic:ready` | `bridges` | The token checked out and the mic screen is showing. | | `firemic:state` | `state`, `elapsed_ms` | Every transition: `idle`, `recording`, `paused`, `processing`, `done`, `error`. | | `firemic:draft` | `narration` | The result. `narration` is the full narration object: `neris`, `transcript`, `gaps`, `timings`, `id`. **Fill your form from this.** | | `firemic:error` | `code`, `message`, `retryable` | A failure the user has already been shown. Codes are the API's plus `NO_TOKEN`, `API_KEY_IN_CLIENT`, `MIC_DENIED`, `MIC_UNSUPPORTED`, `MIC_FAILED`, `EMPTY_RECORDING`, `NETWORK`. | | `firemic:cancel` | | The user tapped Cancel while recording. | Where each bridge delivers: | Host | Delivered via | You receive | |---|---|---| | WebView2 | `window.chrome.webview.postMessage(obj)` | `CoreWebView2.WebMessageReceived`, `e.WebMessageAsJson` | | WKWebView | `window.webkit.messageHandlers.firemic.postMessage(obj)` | a `WKScriptMessageHandler` registered as `"firemic"` | | Android WebView | `window.FireMic.onMessage(json)` | a `@JavascriptInterface` object added as `"FireMic"` | | iframe | `window.parent.postMessage(obj, "*")` | `window.addEventListener("message")`, or the helper below | After `firemic:draft` the page shows the summary and a "Record again" button; most hosts simply close the webview when the draft arrives. ## Windows desktop: WebView2 (C#) Two things: grant the microphone when the page asks, and read the draft. Mint the session from the desktop client with a **department-scoped key** if your product has no server tier; the key never appears in the page. ```csharp using Microsoft.Web.WebView2.Core; using System.Text.Json; await webView.EnsureCoreWebView2Async(); // 1. Microphone permission for our origin, granted without prompting the user twice. webView.CoreWebView2.PermissionRequested += (s, e) => { if (e.PermissionKind == CoreWebView2PermissionKind.Microphone && e.Uri.StartsWith("https://app.fire-mic.com")) e.State = CoreWebView2PermissionState.Allow; }; // 2. The draft. webView.CoreWebView2.WebMessageReceived += (s, e) => { using var doc = JsonDocument.Parse(e.WebMessageAsJson); var type = doc.RootElement.GetProperty("type").GetString(); if (type == "firemic:draft") { var narration = doc.RootElement.GetProperty("narration"); FillIncidentForm(narration.GetProperty("neris"), narration.GetProperty("transcript").GetString()); CloseMicWindow(); } else if (type == "firemic:error") ShowStatus(doc.RootElement.GetProperty("message").GetString()); }; // 3. Open it. var session = await FireMicApi.CreateSession(departmentNerisId, incidentNumber); // POST /v1/sessions webView.CoreWebView2.Navigate($"https://app.fire-mic.com/widget/v1#token={Uri.EscapeDataString(session.Token)}&ref={Uri.EscapeDataString(incidentNumber)}"); ``` WebView2 needs the Evergreen runtime on the workstation; if it is already a dependency of your desktop client, nothing new ships. ## iPhone and iPad: WKWebView (Swift) ```swift import WebKit final class MicViewController: UIViewController, WKScriptMessageHandler, WKUIDelegate { private var webView: WKWebView! override func viewDidLoad() { super.viewDidLoad() let config = WKWebViewConfiguration() config.allowsInlineMediaPlayback = true config.userContentController.add(self, name: "firemic") // 1. the bridge webView = WKWebView(frame: view.bounds, configuration: config) webView.uiDelegate = self view.addSubview(webView) // 3. Open it (token minted by your backend, or with a department-scoped key). var comps = URLComponents(string: "https://app.fire-mic.com/widget/v1")! comps.fragment = "token=\(sessionToken)&ref=\(incidentRef)" webView.load(URLRequest(url: comps.url!)) } // 2. Microphone permission for our origin (iOS 15+). Also add // NSMicrophoneUsageDescription to Info.plist. func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(origin.host == "app.fire-mic.com" && type == .microphone ? .grant : .deny) } func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? [String: Any], let type = body["type"] as? String else { return } switch type { case "firemic:draft": if let narration = body["narration"] as? [String: Any] { fillIncidentForm(narration) ; dismiss(animated: true) } case "firemic:error": showStatus(body["message"] as? String ?? "Something went wrong") default: break } } } ``` ## Android: WebView (Kotlin) ```kotlin class MicActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val web = WebView(this) setContentView(web) web.settings.javaScriptEnabled = true web.settings.mediaPlaybackRequiresUserGesture = false // 1. Microphone permission for our origin. Request android.permission.RECORD_AUDIO // from the user before opening this screen. web.webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { if (request.origin.host == "app.fire-mic.com") request.grant(request.resources) else request.deny() } } // 2. The bridge. The object MUST be added under the name "FireMic". web.addJavascriptInterface(object { @JavascriptInterface fun onMessage(json: String) { val m = JSONObject(json) when (m.getString("type")) { "firemic:draft" -> runOnUiThread { fillIncidentForm(m.getJSONObject("narration")); finish() } "firemic:error" -> runOnUiThread { showStatus(m.getString("message")) } } } }, "FireMic") // 3. Open it. val token = Uri.encode(sessionToken); val ref = Uri.encode(incidentRef) web.loadUrl("https://app.fire-mic.com/widget/v1#token=$token&ref=$ref") } } ``` ## Web page: iframe ```html
``` The helper only creates the iframe (with `allow="microphone"`) and relays its messages to your callbacks, checking the origin. Without the helper, add `allow="microphone"` to your own iframe and listen for `message` events whose `origin` is `https://app.fire-mic.com`. ## Behaviour worth knowing - **Cancel and back.** Cancel discards the recording and posts `firemic:cancel`. If the host closes the webview mid-recording, nothing is sent; the firefighter records again next time. - **Too short.** Recordings under two seconds are refused locally (`EMPTY_RECORDING`) without a network call. A recording with no usable speech comes back from the API as `EMPTY_TRANSCRIPT`, and the page offers to try again. - **Session expiry.** The page checks the token's expiry before recording and shows a clear message if the host opened it with a stale session. Mint the session when the mic is opened, not when the incident was created. - **Offline.** The page needs a connection to transcribe. It tells the user to check the signal and keeps the retry button. Buffering audio for later upload is a native-recording concern, which the [REST API](/developers/api) supports directly. - **Test mode.** A session from a test key shows a "Test mode" badge in the header. - **What it never does.** It never files anything, never sees your NERIS credentials, and never shows a FireMic login. ## Try it without writing code Mint a session with curl and open the URL in any desktop browser: ```bash TOKEN=$(curl -s https://app.fire-mic.com/v1/sessions \ -H "Authorization: Bearer $FIREMIC_KEY" -H "Content-Type: application/json" \ -d '{"department":"FD34007744","external_ref":"TRY-1"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])') open "https://app.fire-mic.com/widget/v1#token=$TOKEN" ``` With no host bridge present the page shows the draft on screen instead of sending it anywhere, which is enough to see the whole flow.