Skip to content

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 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 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.