SDKRecipes

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, poll sessions.retrieve every 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).


Built by Ear3 — voice interviews for any app.
⌘/