Concepts
The mental model for the SDK. Read this once and the API stops being mysterious.
Object hierarchy
Organization ← your workspace at ear3.ai
└── Survey ← the interview "design" (questions + flow)
└── Deployment ← a published, takeable version of the survey
└── Session ← one respondent's attempt at the deployment
└── Response ← persisted answers + transcriptYou create the first three in the dashboard (one-time setup).
interviewId in the SDK = the deployment id.
The SDK creates the bottom two automatically every time someone hits
your <Ear3Interview>. A new session + response per mount — no
clones, no copies of the underlying survey or deployment.
API key types
Three flavors, all generated from Settings → API Keys.
| Type | Prefix | Where it lives | What it can do |
|---|---|---|---|
| Publishable | pk_live_… | Browser bundles, OK to expose | Create sessions for your org’s deployments |
| Secret | sk_live_… | Server env only | Everything — list, retrieve, admin operations |
| Webhook | whsec_… | Server env only | Verify incoming webhook signatures (HMAC-SHA256) |
Each type has _live_ and _test_ modes. Test mode keys can be
generated freely; use them in CI and local development.
Critical: the webhook signing secret is not an authentication credential — it’s a shared symmetric key. Both Ear3 and your server hold the same value. Anyone with it can forge events, so treat it like any other secret.
How a session is created
Two endpoints, doing different things. Both verify the publishable key the same way (SHA-256 hash lookup) and both confirm the deployment belongs to the same org.
Ear3VoiceInterview / Ear3Interview (native path) → POST /api/v2/sdk/connect
- Ear3 mints a
SurveyInvitationand spins up a Pipecat Cloud worker for the room, returning{ room_url, token } - The
VoiceClientinside the SDK connects to that Daily room directly via@pipecat-ai/daily-transport - Voice + RTVI events flow through your React tree — no iframe, no
postMessage
Standalone session URL (createSession / server SDK) → POST /api/v2/sdk/sessions
- Ear3 creates a
SurveyInvitation(anonymous, no email) and anInvitationLinkwith a unique hash - Returns
{ sessionId, sessionUrl, expiresAt }wheresessionUrlishttps://app.ear3.ai/v2/responder/<hash>?from=sdk - Point a respondent at that URL (email link, server component) — the hosted responder page runs the whole voice / RTVI flow
Same underlying session model in both cases. Each mount = one fresh session; reload the parent page = another session. The underlying deployment is never modified.
Call createSession when you need a
session URL without loading the voice component — no React involved.
How the SDK connects
@ear3/voice-interviewer runs the RTVI client directly in your
React tree. The same Pipecat Cloud worker
sits on the other end — you talk to it via
@pipecat-ai/client-js
in your bundle. No iframe, no postMessage.
Your app (parent page)
├── @ear3/voice-interviewer
│ └── <Ear3VoiceInterview> / <Ear3Interview>
│ ├── POST /api/v2/sdk/connect ─────► app.ear3.ai
│ │ └─► spawns Pipecat Cloud worker
│ │ └─► returns { room_url, token }
│ │
│ ├── @pipecat-ai/client-js ← RTVI client (in YOUR bundle)
│ │ └── @pipecat-ai/daily-transport
│ │ │
│ │ │ audio in/out ◄──► Daily SFU ◄──► Pipecat Cloud worker
│ │ │ (STT · LLM · TTS · VAD)
│ │ │
│ │ └── RTVI events → your UI (transcripts, bot-speaking)
│ │
│ └── Default branded UI or your custom renderControls
└─ onComplete(event) fires when bot emits `interview.completed` server-messageBranded widget vs. headless
Same package, two levels of control. Both call the same backend, mint identical sessions, and fire the same webhooks — they differ only in how much of the UI you own.
| Aspect | <Ear3VoiceInterview> (branded) | <Ear3Interview> / VoiceClient (headless) |
|---|---|---|
| Import | package root | /headless subpath |
| Voice UI control | Ear3’s branded turnkey UI, zero work | Full — renderControls or fully custom |
| Bundle size added | ~150 KB (Pipecat client + Daily transport) | ~150 KB |
| Mic permission prompt | Asked in your origin | Asked in your origin |
| RTVI events (transcripts) | Handled for you | Full RTVI stream via callbacks |
| Sessions / webhooks | Identical | Identical |
Neither is “advanced” — pick whichever fits your product. Want a working voice interview in the afternoon? Use the branded widget. Building a custom voice UI (own waveform, own transcript pane, own turn-taking indicator)? Reach for the headless client.
See @ear3/voice-interviewer for the full guide.
The metadata channel
This is how you correlate a session with whatever you call a “user” in your app. Pass any JSON-serializable object:
<Ear3Interview
interviewId={…}
publishableKey={…}
metadata={{
userId: currentUser.id,
plan: 'pro',
cohort: 'march-2026',
}}
/>It round-trips into:
event.data.metadatain the webhook payload your server receivessession.metadatawhen you callear3.sessions.retrieve(sessionId)
Use it. It’s the only stable identifier that connects “this voice interview” to “this user record in my database”.
Webhook flow
Respondent finishes interview
↓
Ear3 marks SurveyInvitation.status = COMPLETED
↓
Ear3 finds all WebhookEndpoints for the org that subscribe to
`interview_completed` and have status `enabled`
↓
For each endpoint:
Build payload { id, type: "interview.completed", created, data: {…} }
Sign: HMAC-SHA256( whsec_ key , `${unix_ts}.${rawBody}` )
POST: Headers include Ear3-Signature: t=<ts>,v1=<sig>
Log: webhook_deliveries row (status, response code, body, error)
↓
Your /api/webhooks/ear3 handler receives it
↓
@ear3/server ear3.webhooks.constructEvent(rawBody, signature, whsec)
- parses + verifies signature with constant-time compare
- rejects timestamps older than 5 min (default)
- returns typed Ear3Event
↓
Switch on event.type → your business logicDelivery is fire-and-forget on Ear3’s side with an 8-second timeout per attempt. A 2xx counts as success. (Retries are roadmapped — see Troubleshooting.)
Event types
| Event | When it fires |
|---|---|
interview.started | Respondent opened the link and the session began |
interview.completed | Respondent finished all questions |
interview.failed | Voice pipeline crashed mid-interview |
session.expired | Link expired before the respondent reached the end |
You subscribe per-endpoint when you register the webhook in the
dashboard. New event types may be added — handle unknown types
gracefully (the template already does — see
app/api/webhooks/ear3/route.ts).
What lives where
| Concern | Where it actually runs |
|---|---|
| Question/flow design | Ear3 dashboard (you don’t write the questions in your code) |
| Voice pipeline (STT, LLM, TTS) | Ear3’s Pipecat infrastructure |
| Session creation API | Ear3 backend (called by the browser SDKs) |
| Transcript storage | Ear3 backend |
| Webhook signing | Ear3 backend |
| Your responsibility | Mount the component, handle the webhook |