REST API
The widget POSTs to these; you can too. All paths under /v1/* accept the project Bearer key via headers:
x-earshot-project: prj_xxxxxxxxxxxx
x-earshot-key: pk_xxxxxxxxxxxxxxxx
Missing or wrong key → 401. Missing project header → 400. Constant-time comparison; per-project audit logged in production.
POST /v1/feedback
Submit feedback. One-shot — there are no follow-up rounds.
POST https://app.earshotbot.com/v1/feedback
Content-Type: multipart/form-data
audio: <File> optional, audio/webm or audio/mp4
screenshot: <File> optional, image/png
payload: <JSON> required
Payload schema:
{
sessionId: string; // UUID v7 from the widget's session cookie
transcript: string; // empty if audio is being sent (server transcribes)
context: CapturedContext; // see "Captured context" below
identity: Identity | null;
audioDurationMs?: number; // recorded duration of the audio, if any
}
Returns 202 immediately after the upload completes:
{ "sessionId": "<uuid>", "status": "processing" }
The transcription + LLM enrichment + ingest run in a background Inngest worker. The widget treats the 202 as "filed" and doesn't normally wait; a client that wants progress can poll the state endpoint below.
GET /v1/feedback/session/[sessionId]/state
Poll the async progress of a prior POST. Same auth headers as POST.
// Still working
{ "status": "processing" }
// Finished — feedback row exists
{ "status": "done", "id": "fb_abc123...", "transcript": "..." }
// Anything failed (no API key, transcription failed, etc.)
{ "status": "error", "error": "transcribe failed" }
Returns 404 once the session has aged out (1 hour TTL).
POST /v1/identify
Backfill identity onto every feedback row in the session that's still anonymous.
POST https://app.earshotbot.com/v1/identify
Content-Type: application/json
x-earshot-project, x-earshot-key
{
"sessionId": "<uuid>",
"identity": {
"userId": "u_123",
"email": "user@host.com",
"name": "Pat User",
"traits": { "plan": "pro" }
}
}
Response: { "ok": true, "updated": N } — count of rows backfilled. Linked tracker issues + future "resolve" emails pick up the identity automatically.
PATCH /v1/feedback/[id]
Update status, priority, resolution note, or labels.
PATCH https://app.earshotbot.com/v1/feedback/fb_abc123
Content-Type: application/json
x-earshot-project, x-earshot-key
{
"status": "done", // optional
"priority": 1, // 0-4 (0 = highest)
"resolutionNote": "Fixed in v1.4", // cleared when status moves back to non-resolved
"labels": ["bug", "auth"] // replaces the list
}
Setting status to done or canceled:
- Stamps
resolved_at. - Mirrors the resolution as a comment on the linked Linear issue.
- Emails the submitter (if identity.email is set +
RESEND_API_KEYconfigured).
Reverting clears those.
GET + POST /v1/feedback/[id]/comments
GET → list comments oldest-first
POST → append one comment
POST body:
{
body: string; // 1-8000 chars
authorType: 'human' | 'agent' | 'submitter'; // default 'human'
authorId?: string | null; // free-form, e.g. 'claude-opus-4-7'
notifySubmitter?: boolean; // emails identity.email
mirrorToTracker?: boolean; // appends to linked Linear issue
}
Captured context
Auto-populated by the widget, validated server-side via Zod:
{
dom: { format: 'rrweb-full-snapshot-v2', data: unknown, capturedAt: string } | null,
console: Array<{ level, message, stack, timestamp }> // max 50
network: Array<{ url, method, status, durationMs, initiator, timestamp }>, // max 10
device: { userAgent, platform, language, viewport: {width, height}, devicePixelRatio, timezone },
page: { url, referrer, title, customAttrs },
screenshotUrl: string | null,
annotationLayer: unknown | null,
capturedAt: string, // ISO timestamp
}
CORS
/v1/* accepts cross-origin POST/PATCH/GET with Access-Control-Allow-Origin echoing the request origin. Headers allowed: content-type, x-earshot-project, x-earshot-key, authorization. Methods: GET, POST, PATCH, DELETE, OPTIONS.
Per-project origin allowlist enforcement ships in Sprint 5 — until then any origin can POST with a valid key.
Rate limiting
None enforced today. Documented limits will be:
- 100 submissions / minute per project (sliding window)
- 10 MB per audio file, 5 MB per screenshot
- 50 MB cumulative storage per submission
In production: 429 with Retry-After.