Recipes
Patterns that come up over and over. Each one is a copy-pasteable snippet plus the reasoning behind it.
Recipe 1 — Correlate session with your user
Problem: Ear3 doesn’t know who your user is. When the webhook fires, you need to update that user’s record.
Solution: pass user id in metadata. It round-trips.
// app/interview/page.tsx
<Ear3Interview
interviewId={…}
publishableKey={…}
metadata={{ userId: currentUser.id }}
/>// app/api/webhooks/ear3/route.ts
if (event.type === 'interview.completed') {
const userId = (event.data.metadata as { userId?: string })?.userId
if (userId) {
await db.user.update({
where: { id: userId },
data: {
onboardingSessionId: event.data.sessionId,
onboardingCompletedAt: new Date(),
},
})
}
}Type the metadata once to avoid typos:
// types/ear3.ts
export interface OnboardingMetadata {
userId: string
cohort?: string
}metadata={{ userId, cohort: 'march-2026' } satisfies OnboardingMetadata}Recipe 2 — Pre-create a session server-side, email it
Problem: you want to invite people via email — no iframe at the moment of send.
Solution: create the session with @ear3/server, email the URL.
// app/api/invite/route.ts
import { Ear3 } from '@ear3/server'
const ear3 = new Ear3(process.env.EAR3_CONFIG_CLI_KEY!)
export async function POST(req: Request) {
const { email, userId } = await req.json()
const session = await ear3.sessions.create({
interviewId: process.env.EAR3_INTERVIEW_ID!,
metadata: { userId, source: 'email-invite' },
})
await sendEmail(email, {
subject: 'Quick 3-minute interview',
body: `Tap here when you have a moment: ${session.sessionUrl}`,
})
return Response.json({ ok: true })
}The recipient lands directly on the responder — no iframe needed.
Recipe 3 — Show transcript on the thank-you page
Problem: after the interview, you want to render what the respondent said.
Solution: the onComplete callback gives you a session id; fetch
the full session in a server component or via your API.
// app/done/page.tsx (Server Component)
import { Ear3 } from '@ear3/server'
const ear3 = new Ear3(process.env.EAR3_CONFIG_CLI_KEY!)
export default async function Done({
searchParams,
}: {
searchParams: { session?: string }
}) {
if (!searchParams.session) return <p>Done.</p>
const session = await ear3.sessions.retrieve(searchParams.session)
return (
<div>
<h1>Thanks!</h1>
<p>Status: {session.status}</p>
{session.response?.summary && (
<blockquote>{session.response.summary}</blockquote>
)}
</div>
)
}⚠️ The webhook may arrive after the respondent navigates to
/done. If the summary isn’t ready yet, pollsessions.retrieveevery 2s for up to ~30s, or rely on the webhook to push the final state to your DB.
Recipe 4 — Multi-step onboarding (chained interviews)
Problem: you have two short interviews — “what brings you here?” followed by “what are your goals?” — and want to chain them.
Solution: two <Ear3Interview> mounts behind a state machine, each
with a different interviewId.
// app/onboarding/page.tsx
'use client'
import { useState } from 'react'
import { Ear3Interview } from '@ear3/voice-interviewer/headless'
const INTERVIEWS = [
{ id: 'dpl_intro_…', step: 'intro' },
{ id: 'dpl_goals_…', step: 'goals' },
] as const
export default function Onboarding() {
const [idx, setIdx] = useState(0)
if (idx >= INTERVIEWS.length) return <p>All done 🎉</p>
const current = INTERVIEWS[idx]
return (
<Ear3Interview
key={current.id} // force fresh mount each step
interviewId={current.id}
publishableKey={process.env.NEXT_PUBLIC_EAR3_KEY!}
metadata={{ step: current.step, userId: 'usr_42' }}
onComplete={() => setIdx((i) => i + 1)}
/>
)
}The key prop forces React to unmount and remount when interviewId
changes — otherwise the component could try to reuse the previous session.
Recipe 5 — Custom loading + error UI
Problem: the default “Preparing interview…” text doesn’t match your brand.
<Ear3Interview
interviewId={…}
publishableKey={…}
loadingFallback={
<div style={{ padding: 32, textAlign: 'center' }}>
<YourSpinner />
<p>Warming up the microphone…</p>
</div>
}
errorFallback={(err) => (
<div role="alert" style={{ padding: 32 }}>
<h3>Hmm, something{`'`}s off.</h3>
<p>{err.message}</p>
<button onClick={() => location.reload()}>Try again</button>
</div>
)}
/>Recipe 6 — Verify webhooks with Express (not Next.js)
import express from 'express'
import { Ear3, SignatureVerificationError } from '@ear3/server'
const ear3 = new Ear3(process.env.EAR3_CONFIG_CLI_KEY!)
const app = express()
// CRITICAL: express.raw, not express.json
app.post(
'/webhooks/ear3',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = ear3.webhooks.constructEvent(
req.body.toString('utf8'),
req.headers['ear3-signature'] as string,
process.env.EAR3_WEBHOOK_SECRET!,
)
// your business logic …
res.json({ received: true })
} catch (err) {
if (err instanceof SignatureVerificationError) {
return res.status(400).send('Invalid signature')
}
throw err
}
},
)Recipe 7 — Test mode in CI
Problem: your CI runs integration tests against a real Ear3 backend but you don’t want to pollute live data.
Solution: use _test_ keys + a separate test interview.
# .github/workflows/test.yml
env:
NEXT_PUBLIC_EAR3_VOICE_INTERVIEWER_KEY: ${{ secrets.EAR3_PK_TEST }}
EAR3_CONFIG_CLI_KEY: ${{ secrets.EAR3_SK_TEST }}
EAR3_WEBHOOK_SECRET: ${{ secrets.EAR3_WHSEC_TEST }}
NEXT_PUBLIC_EAR3_INTERVIEW_ID: ${{ secrets.EAR3_TEST_INTERVIEW }}Test-mode sessions and responses don’t count against your usage quotas (TBD — test-mode isolation is roadmapped).