API Reference
Every SDK call — @ear3/voice-interviewer, @ear3/server — is a
thin wrapper over these HTTP endpoints. Reach for the REST API when a
client library isn’t available or you need to script something
bespoke.
Base URL: https://app.ear3.ai
Authentication
Two key types, two auth headers.
Publishable keys (pk_…)
Safe to expose in browsers. Passed as a body field on session creation, not as a header.
POST /api/v2/sdk/sessions
Content-Type: application/json
{
"publishableKey": "pk_test_…",
"interviewId": "dpl_…",
"metadata": { "userId": "42" }
}Secret keys (sk_…)
Server-only. Passed as a bearer token.
GET /api/v2/sdk/sessions/inv_abc123…
Authorization: Bearer sk_test_…Webhook signing (whsec_…)
Not sent on requests — used to verify incoming webhooks on your server. See Webhooks below.
Sessions
A session is one respondent’s attempt at a deployed interview. Each
mount of <Ear3Interview> / <Ear3VoiceInterview> in
@ear3/voice-interviewer creates a fresh session via the endpoint below.
POST /api/v2/sdk/sessions
Create a session for a deployment.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
publishableKey | string | ✓ | pk_test_… or pk_live_… |
interviewId | string | ✓ | Deployment id from the dashboard |
metadata | object | Round-trips into webhook + session.retrieve |
Response 201 Created
{
"sessionId": "inv_abc123…",
"sessionUrl": "https://app.ear3.ai/v2/responder/abc123?from=sdk",
"expiresAt": "2026-07-01T18:00:00Z"
}Iframe the sessionUrl — that’s where the voice pipeline lives. The
sessionId is what you’ll receive back in the webhook and pass to
GET /sessions/:id.
GET /api/v2/sdk/sessions/{sessionId}
Retrieve session state. Requires sk_ bearer. Publishable keys
are rejected here (no CORS either) so this call must be made from
your server.
Response 200 OK
{
"id": "inv_abc123…",
"interviewId": "dpl_…",
"status": "COMPLETED",
"createdAt": "2026-07-01T17:12:03Z",
"clickedAt": "2026-07-01T17:12:40Z",
"completedAt": "2026-07-01T17:15:11Z",
"metadata": { "userId": "42" },
"url": "https://app.ear3.ai/v2/responder/abc123?from=sdk",
"expiresAt": "2026-07-01T18:00:00Z",
"response": {
"id": "rsp_…",
"status": "COMPLETED",
"transcriptKey": "s3://ear3-transcripts/…",
"summary": "User wants dark mode by default and shorter onboarding."
}
}Statuses: PENDING → SENT → CLICKED → STARTED → COMPLETED (or EXPIRED).
Webhooks
Ear3 delivers webhooks when interview lifecycle events happen. Every
delivery is HMAC-signed with your whsec_… key.
Signature header
Ear3-Signature: t=1719849823,v1=5f8a3c1b09e4…t— Unix timestamp (seconds) of the signing timev1=— HMAC-SHA256 of${t}.${rawBody}usingwhsec_…as the key
You may see multiple v1= values in the same header during a key
rotation window; consider a match on any of them a success.
Verifying (Node)
import crypto from 'node:crypto'
function verify(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
)
const ts = Number(parts.t)
if (Math.abs(Date.now() / 1000 - ts) > 300) return false // 5-min window
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
)
}🛑 Verify against the raw body, not
JSON.parse(...)d. Any whitespace change breaks the signature.
If you’re on Node, @ear3/server’s ear3.webhooks.constructEvent()
does all of this — see the SDK reference.
Event payload shape
{
"id": "evt_2xY…",
"type": "interview.completed",
"created": 1719849823,
"data": {
"sessionId": "inv_abc123…",
"interviewId": "dpl_…",
"metadata": { "userId": "42" },
"completedAt": "2026-07-01T17:15:11Z"
}
}Event types
| Event | When it fires |
|---|---|
interview.completed | Respondent finished all questions |
interview.failed | Voice pipeline crashed mid-interview |
session.expired | Link expired before the respondent reached the end |
Subscribe per-endpoint in the dashboard (Settings → Webhooks). New event types may be added — handle unknown types gracefully in your handler.
Delivery guarantees
- Timeout: 8 seconds per attempt
- Success: any 2xx response
- Retries: not currently — a 4xx or 5xx means the event is dropped (retries with exponential backoff are on the roadmap)
Every attempt is logged to webhook_deliveries in your workspace —
inspect it if something didn’t fire the way you expected.
Errors
Every non-2xx response follows a common shape.
{
"error": "interview_not_found",
"message": "Deployment dpl_xyz does not exist in your workspace"
}| Status | error code | When |
|---|---|---|
| 400 | invalid_request | Missing / malformed field in the body |
| 401 | invalid_key | Missing or unrecognized pk_ / sk_ |
| 403 | cross_workspace | Key + resource belong to different workspaces |
| 404 | interview_not_found | interviewId doesn’t resolve |
| 404 | session_not_found | sessionId doesn’t resolve |
| 409 | interview_not_active | Deployment status is DRAFT or PAUSED |
| 410 | interview_expired | Deployment expiresAt is in the past |
| 429 | rate_limit_exceeded | Hourly or daily cap hit for this workspace |
| 500 | internal_error | Something on our side — retry with exponential backoff |
The @ear3/server SDK maps every non-2xx to an Ear3Error with .status
and .code fields matching the values above.