Is Otter.ai: Record & Transcribe Meetings - Google Meet & Web Audio safe?
Otter.ai is high risk. After a signed-in user starts recording, it opens a recording tab, captures audio, opens Otter.ai's transcription WebSocket, sends a start message, then forwards audio buffers. Unauthenticated checks didn't trigger this; login is required.…
Who publishes itOtter.ai - no other listings under this identity, 3 shared hostnames
Otter.ai - no other listings under this identity, 3 shared hostnames
What this publisher told the store about itself, and the other listings that told it the same thing.
Shared hosts - 3 hostnames
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Tab audio streams to Otter.ai after recording starts
After a signed-in user starts recording, it opens a recording tab, captures audio, opens Otter.ai's transcription WebSocket, sends a start message, then forwards audio buffers.
Unauthenticated checks didn't trigger this; login is required.
You start an Otter.ai recording from the extension.
The code path requires an authenticated Otter.ai session and a recording action.
The extension captures audio from the active browser tab and sends it to Otter.ai for transcription.
The recording tab sends a JSON start message first, followed by binary audio buffers.
| Field | Value | Why it matters | |
|---|---|---|---|
Recording identifier | speech_id from the recording-start response | Lets Otter.ai associate the audio stream with the transcript you started. | |
Processed offset | offset: 0 | Tracks how much of the audio stream has already been acknowledged. | |
Browser-tab audio frames | 16 kHz audio buffer, 1024 samples | Audio playing in the captured tab can include meeting speech, media playback, or other spoken content. | |
Capture stream handle | stream handle for the active tab audio | Connects the recording tab to the browser tab whose audio is being recorded. |
Shipped and deobfuscated recording path evidence
function EV() {
const [, t] = gt(So), [, e] = gt(Q1), [, n] = gt(a6), [r] = gt(o6), [i] = gt(N$), {
mutate: o
} = sGe(), {
data: s
} = Co(), a = l6(sv.audioTag, "enabled"), l = et() ? yve : _ve;
return {
startRecording: h.useCallback(async (d = !1) => {
if (!await mZe()) {
Is(), Os(c.jsx(Ls, {
"data-testid": "cannot-start-recording-toast",
description: JD,
altText: JD,
showCloseIcon: !0
}), {
duration: Ss.endCurrentRecording
});
return
}
s && o({
queryParams: {
appid: l,
uuid: gE(),
userid: JSON.stringify(s.userid),
start_time: Math.floor(Date.now() / 1e3),
ignore_event: !0
}
}, {
onSuccess: p => {
if (et() && a) {
pZe(p.ws_url, p.speech_id, d, r, i, JSON.stringify(s.userid), p.otid, l), e(p.otid), t("liveTranscription"), n(!0);
return
}
const g = {
action: Pi.BG_RECORDING_ACTION,
type: "start",
wsUrl: p.ws_url,
speechId: p.speech_id,
micEnabled: d,
micMuted: r,
deviceLabel: i,
userId: JSON.stringify(s.userid),
otid: p.otid,
appId: l
};
Rn.runtime.sendMessage(g), e(p.otid), t("liveTranscription")
},
onError: p => {
const g = p;
g.message && (Is(), Os(c.jsx(Ls, {
"data-testid": "recording-error-toast",
description: g.message,
altText: g.message,
showCloseIcon: !0
}), {
duration: Ss.error
}))
}
})
}, [r, i, a, e, t, n, o, s, l])
}
}async function Nd(t) {
if (!t.tabId) throw new Error("[Background] No active tab ID when starting recording");
let e;
try {
e = await chrome.tabs.get(t.tabId)
} catch (b) {
throw new Error(`[Background] Active tab does not exist anymore, ${b}`)
}
const r = await navigator.permissions.query({
name: "microphone"
}),
a = r.state === "prompt" || r.state === "denied",
o = t.micEnabled && a,
s = new Promise(b => {
const P = y => {
y.action === yt.RT_READY && (chrome.runtime.onMessage.removeListener(P), b(!0))
};
chrome.runtime.onMessage.addListener(P)
}),
p = await new Promise((b, P) => {
chrome.tabs.create({
url: chrome.runtime.getURL("recordingtab.html"),
pinned: !0,
active: o
}, y => {
if (chrome.runtime.lastError) return P(new Error(`[Background] ${chrome.runtime.lastError.message}`));
b(y.id)
})
});
if (!p) {
Rr({
errorMessage: "[Background] Failed to create recording tab",
shouldThrow: !0,
csTabId: e.id
});
return
}
await s;
const d = {
action: yt.RT_MIC_ENABLED,
type: t.micEnabled ? "enabled" : "disabled"
};
chrome.tabs.sendMessage(p, d), Di(!0, p, e.id), await new Promise(b => {
setTimeout(() => {
b(!0)
}, 1e3)
});
const m = await new Promise((b, P) => {
chrome.tabCapture.getMediaStreamId({
targetTabId: e.id,
consumerTabId: p
}, y => {
if (chrome.runtime.lastError) {
P(new Error(`[Background] Tabcapture: ${chrome.runtime.lastError.message}`));
return
}
b(y)
})
}),
g = {
action: yt.RT_RECORDING_ACTION,
type: "start",
data: {
wsUrl: t.wsUrl,
speechId: t.speechId,
tabAudioStreamId: m,
micEnabled: t.micEnabled,
micMuted: t.micMuted,
deviceLabel: t.deviceLabel,
tabFocused: o,
userId: t.userId,
otid: t.otid,
appId: t.appId
}
};
chrome.runtime.sendMessage(g)
}async function Cp(e) {
if (pe = new Wr(e.wsUrl), Fr = e.speechId, yg = e.micEnabled, Fi = e.userId, Ii = e.otid, Pi = e.appId, ef = Math.floor(Date.now() / 1e3), Wi = new Op, pe.onopen = async () => {
pe == null || pe.send(JSON.stringify({
action: "start",
speech_id: e.speechId,
offset: ru
})), fu = !0, await Wi.read(e.speechId, a => {
if (a.length > 0)
for (const l of a) pe == null || pe.send(l)
})
}, pe.onmessage = async a => {
const l = Np(a.data);
if (!l) return;
const n = hp.safeParse(l);
if (!n.success) return;
n.data.type === "ack" && (ru = n.data.result.processed_offset + 1, ru > Ir && (await Wi.remove(e.speechId, 2 * (ru - Ir)), Ir = ru))
}, pe.addEventListener("error", () => {
fu = !1
}), pe.addEventListener("close", () => {
fu = !1
}), e.micEnabled) {
e.tabFocused && await navigator.mediaDevices.getUserMedia({
audio: !0
});
const l = (await navigator.mediaDevices.enumerateDevices()).filter(u => u.label === e.deviceLabel && u.kind === "audioinput");
let n = "default";
l.length > 0 && l[0] && (n = l[0].deviceId), jt = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: {
exact: n
}
}
})
}
const t = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: "tab",
chromeMediaSourceId: e.tabAudioStreamId
}
}
});
e.micMuted && tf(!0), Oe && await Oe.close(), Oe = new AudioContext({
latencyHint: "interactive",
sampleRate: 16e3
}), Oe.state === "suspended" && (console.error("[Recording tab] AudioContext is suspended on creation"), await Oe.resume());
try {
await Oe.audioWorklet.addModule("src/shared/tab-audio-processor.js")
} catch (a) {
throw new Error(`[Recording tab] ${a}`)
}
su = Oe.createMediaStreamSource(t), jt && (rl = Oe.createMediaStreamSource(jt)), su.connect(Oe.destination), xt = pg(rl, su, Oe), cu = new AudioWorkletNode(Oe, "tab-audio-processor"), xt.connect(cu), cu.port.onmessage = async a => {
const l = a.data.audioBuffer.buffer;
fu && (pe == null || pe.send(l)), await Wi.append(e.speechId, l)
}
}var _ = function(t) {
"use strict";
var l = Object.defineProperty;
var b = (t, e, r) => e in t ? l(t, e, {
enumerable: !0,
configurable: !0,
writable: !0,
value: r
}) : t[e] = r;
var f = (t, e, r) => b(t, typeof e != "symbol" ? e + "" : e, r);
class e extends AudioWorkletProcessor {
constructor() {
super(...arguments);
f(this, "bufferSize", 1024);
f(this, "buffer", new Int16Array(this.bufferSize));
f(this, "bytesWritten", 0)
}
isBufferFull() {
return this.bytesWritten === this.bufferSize
}
appendToBuffer(s) {
this.isBufferFull() && this.flush(), this.buffer[this.bytesWritten] = s, this.bytesWritten += 1
}
flush() {
let s = this.buffer;
this.bytesWritten < this.bufferSize && (s = s.slice(0, this.bytesWritten)), this.port.postMessage({
eventType: "data",
audioBuffer: s
}), this.bytesWritten = 0
}
process(s) {
const a = s.length;
for (let u = 0; u < a; u++) {
const i = s[u];
if (!(!i || !i[0]))
for (let o = 0; o < i[0].length; o++) {
if (!i.length) continue;
const n = Math.max(-1, Math.min(1, 1 * i[0][o])),
h = n < 0 ? n * 32768 : n * 32767;
this.appendToBuffer(h)
}
}
return !0
}
}
return registerProcessor("tab-audio-processor", e), t.TabAudioProcessor = e, Object.defineProperty(t, Symbol.toStringTag, {
value: "Module"
}), t
}({});- ws.aisense.com
Otter.ai transcription WebSocket that receives the start message and browser-tab audio frames.
Otter.ai session cookies copied to Aisense API
The extension reads csrftoken/sessionid cookies from your Otter.ai session, writes the same names/values for api.aisense.com, and calls the Aisense login CSRF endpoint.
Unauthenticated testing didn't trigger this; it needs a session first.
You use the extension while signed in to Otter.ai.
The background service worker reads your Otter.ai session cookies and writes matching cookies for the Aisense API domain.
| Field | Value | Why it matters | |
|---|---|---|---|
Otter CSRF token | csrftoken=9f0a7b6c2d1e4a8b9c0d3e5f6a7b8c9d (illustrative) | This value links browser requests back to your signed-in Otter.ai session. | |
Otter session ID | sessionid=4b7f2d9e8c1a43f0a6d5c2b1e9f08372 (illustrative) | This value represents your active Otter.ai browser session and can carry account context to the API domain. | |
Destination API domain | https://api.aisense.com | This is the domain that receives the copied cookie names and values. |
| Content-Type | text |
Background helpers copy Otter cookies to the Aisense API domain
async function fd() {
const t = "https://otter.ai",
e = "https://api.aisense.com";
try {
const r = await de(t, "csrftoken"),
a = await de(t, "sessionid");
await wn("csrftoken", r, e), await wn("sessionid", a, e)
} catch {}
const n = await fetch(`${e}/api/v1/${fe.LOGIN_CSRF}`, {
method: "GET",
headers: {
"Content-Type": "text"
}
});
try {
const r = await de(t, "csrftoken"),
a = await de(t, "sessionid");
await wn("csrftoken", r, e), await wn("sessionid", a, e)
} catch {}
return n.json()
}function de(t, e) {
return new Promise((n, r) => {
chrome.cookies.get({
url: t,
name: e
}, function(a) {
a ? n(a.value) : r(chrome.runtime.lastError)
})
})
}function wn(t, e, n) {
return new Promise((r, a) => {
chrome.cookies.set({
name: t,
value: e,
url: n
}, o => {
o ? r(o.value) : a(chrome.runtime.lastError)
})
})
}- otter.ai
Provides the csrftoken and sessionid cookie values read by the background service worker.
- api.aisense.com
Receives csrftoken and sessionid cookies and the /api/v1/login_csrf request.
Greenhouse candidate name sent to Otter search
With Greenhouse integration on, clicking Otter's control on an interview page reads the candidate name, page URL, job title, and form context, then queries Otter's advanced_search endpoint for matches.
No live request captured.
You use Otter's Greenhouse integration on an interview feedback page.
The content script reads candidate and form context, then searches Otter conversations using the candidate name.
| Field | Value | Why it matters | |
|---|---|---|---|
Candidate name | Jane Candidate (illustrative) | This identifies the applicant whose Greenhouse page you are viewing. | |
Greenhouse page URL | https://app.greenhouse.io/guides/interview_kits/12345/people/67890 (illustrative) | This ties the action to a specific Greenhouse workflow page in your browser. | |
Interview form fields | Problem Solving, Communication, Technical Depth (illustrative) | This describes the evaluation fields visible on the page. | |
Job and interview metadata | Senior Product Manager, Onsite Interview (illustrative) | This adds hiring context to the transcript search and generated feedback. |
Greenhouse content script extracts candidate/page context and sends the search message
async function Cy() {
var e, a;
const t = await $t.candidateName.getSelectorString();
return ((a = (e = document.querySelector(t)) == null ? void 0 : e.textContent) == null ? void 0 : a.trim()) || void 0
}async function Ry() {
const [t, e, a, l, r, n, o, i, c] = await Promise.all([_y(), Ay(), ky(), Ty(), Dy(), Fy(), Ny(), zy(), Cy()]), u = {
formFields: e,
pageUrl: window.location.href,
extractedAt: new Date().toISOString()
};
if (t !== "unknown" && (u.pageType = t), i !== void 0 && (u.jobTitle = i), c !== void 0 && (u.candidateName = c), l !== void 0 || a.length > 0 || r !== void 0 || n.length > 0 || o !== void 0) {
const g = {};
l !== void 0 && (g.interviewStep = l), a.length > 0 && (g.focusAttributes = a), r !== void 0 && (g.interviewGuide = r), n.length > 0 && (g.scorecardAttributes = n), o !== void 0 && (g.keyTakeawaysPrompt = o), u.metadata = g
}
return u
}async function jy() {
var r, n;
if (!await My()) {
console.warn("[Greenhouse Prototype] Not an interview feedback page, skipping"), alert("This doesn't appear to be a Greenhouse interview feedback page.");
return
}
const t = await Ry();
console.info("[Greenhouse Prototype] Extracted data:", t);
const e = t.formFields.length > 0,
a = !!((n = (r = t.metadata) == null ? void 0 : r.scorecardAttributes) != null && n.length);
if (!e && !a) {
console.warn("[Greenhouse Prototype] No form fields or scorecard attributes found"), alert("No form fields or scorecard attributes found on this page. The page structure may have changed.");
return
}
if (!t.candidateName) {
console.warn("[Greenhouse Prototype] No candidate name found"), alert("Cannot find candidate name on this page. Please make sure you are on a Greenhouse interview feedback page.");
return
}
const l = await Uy(t.candidateName);
l && await By(l, t)
}async function Uy(t) {
console.info(`[Greenhouse Prototype] Searching for conversations with candidate: ${t}`);
try {
const e = {
type: "GREENHOUSE_SEARCH_CONVERSATIONS",
candidateName: t
},
{
success: a,
error: l,
conversations: r = []
} = await chrome.runtime.sendMessage(e);
if (!a) return console.error("[Greenhouse Prototype] Failed to search conversations:", l), alert(`Failed to search conversations: ${l}`), null;
if (console.info("[Greenhouse Prototype] Found conversations:", r), r.length === 0) return alert(`No interview conversations found for "${t}". Please make sure you have a recorded interview with this candidate.`), null;
if (r.length === 1) {
const {
speech_otid: o,
title: i
} = r[0];
return console.info(`[Greenhouse Prototype] Auto-selected conversation with speech_otid=${o} and title=${i}`), o
}
const n = await Oy(r);
return n ? n.speech_otid : (console.info("[Greenhouse Prototype] User cancelled conversation selection"), null)
} catch (e) {
return console.error("[Greenhouse Prototype] Uncaught error in findSpeechOtidByCandidateName:", e), alert("Failed to find conversation. Please try again."), null
}
}Background service worker converts the candidate name into an Otter search request
async function yd(t) {
const e = `${rn}/advanced_search?query=${encodeURIComponent(t)}&relevance=true&size=5`,
n = await de(Nt, "csrftoken");
return await fetch(e, {
method: "GET",
credentials: "include",
headers: {
"x-csrftoken": n
}
})
}async function Pd(t) {
try {
console.info("[Greenhouse Prototype] Searching for conversations with candidate:", t);
const e = await yd(t);
if (console.info("[Greenhouse Prototype] Search response status:", e.status), !e.ok) {
const r = await e.text();
return console.error("[Greenhouse Prototype] Search API error:", r), {
success: !1,
error: `Search API returned ${e.status}: ${r}`
}
}
const n = await e.json();
return console.info("[Greenhouse Prototype] Search results:", n), console.info("[Greenhouse Prototype] Conversations found:", n.hits.length), {
success: !0,
conversations: n.hits
}
} catch (e) {
return console.error("[Greenhouse Prototype] Search exception:", e), {
success: !1,
error: e instanceof Error ? e.message : String(e)
}
}
}async function Id(t) {
if (t.type === "GREENHOUSE_SEARCH_CONVERSATIONS") {
console.info("[Greenhouse Prototype] Searching for conversations");
const e = await Pd(t.candidateName);
return console.info("[Greenhouse Prototype] searchConversations result:", e), e
}
if (t.type === "GREENHOUSE_GENERATE_FORM_FILLS") {
console.info("[Greenhouse Prototype] Generating form fills");
const e = await wd(t.speechOtid, t.payload);
return console.info("[Greenhouse Prototype] generateGreenhouseFormFills result:", e), e
}
return {
success: !1,
error: "Unknown message type"
}
}- otter.ai
Receives the candidate-name query at /forward/api/v1/advanced_search.
+1 more finding not shown