Developers · Partner API
Embedding the hosted mic
The shortest integration. Open one page in a webview or an iframe with a session token, listen for one message, fill your form. Recording, upload and the draft all happen inside the page, so improvements ship without you redeploying.
Last updated September 2026.
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 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), then open
https://app.fire-mic.com/widget/v1#token=<session token>&ref=<your incident reference>
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.
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)
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)
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
<div id="mic"></div>
<script src="https://app.fire-mic.com/widget/v1.js"></script>
<script>
// sessionToken came from YOUR backend (POST /v1/sessions). The key stays there.
const widget = FireMic.mount(document.getElementById('mic'), {
sessionToken,
externalRef: 'INC-2026-001847',
onDraft: (narration) => { fillIncidentForm(narration.neris, narration.transcript); widget.unmount(); },
onError: (err) => console.warn(err.code, err.message),
});
</script>
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 asEMPTY_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 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:
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.