---
title: "Report Goal Voice Playback"
method: POST
path: "/goals/voice/session/{session_id}/playback"
tags: ["goals"]
---

# Report Goal Voice Playback

`POST /goals/voice/session/{session_id}/playback`

## Request

- Base URL: `https://api.imperfect.co` (the document declares no server; this is the origin it was published from)
- URL: `https://api.imperfect.co/goals/voice/session/{session_id}/playback`
- Auth: HTTP bearer

Take the page's account of what the athlete actually heard (#2758).

The relay sees its own half of a conversation and no further. It cannot see
a worklet that died, a playback buffer that ran dry, a microphone the
athlete took back, or a capture that was clipping the whole time — all four
read from here as a healthy relay whose socket went away. This is the one
door that hears about them, and it is the page's own report, sent once,
after it ends.

**The path's id is the ticket, so nothing is echoed in the body.** The mint
answers with it and the audio socket is opened on it, so the page already
holds the only per-conversation key both ends know; stamping it beside the
mint's own ``session_ticket`` is what turns two spans into one
conversation. The ticket is **not** redeemed or verified here. It is
single-use and the socket spends it, so a check would refuse every report
ever sent — the other way round from the socket, where spending it *is* the
check. What stands in for it is the browser boundary this route takes like
every other unsafe one, so a wrong id costs a caller a span that joins
nothing rather than a look at someone else's conversation.

**Not named ``telemetry``.** looking-glass#539 found that word, along with
``collect`` and ``beacon``, on common ad-block filter lists, which is why
the page's own end-of-conversation report posts to ``/api/client-error``. A
report an extension silently drops is indistinguishable from a page that
never sent one, and the whole value here is the denominator.

``204`` because there is nothing to answer with: the page is reporting, not
asking, and a body would only be something for a producer to start
depending on.

## Path parameters

- `session_id` string, required

## Headers

- `x-device-timezone` string, nullable
- `x-device-hint` string, nullable
- `x-client-platform` string, nullable

## Request body

- GoalVoiceTelemetryRequest: What the speaker and the microphone did, which only the page can see (#2758). The server watches a conversation from the middle of the relay: it knows what it sent and when the socket went away, and nothing about what the athlete actually heard. A worklet that crashed, a buffer that ran dry, a microphone revoked mid-sentence and a clipping capture all read as one healthy relay from here. This is the page saying so, once, at the end. **It never reads ``CloseEvent.code``, and neither does its producer** (looking-glass#914). #2698 binds every child of the socket-telemetry umbrella to that, because #2704 measured a 4404 refusal arriving at a real browser as ``1005`` with ``wasClean: true``, three times out of three: the Cloudflare and Render path strips the status, so a page reasoning about a close code reasons about a number the network invented. How it ended stays the server's to say, on the durable ``goal_call_transcripts`` row (#2701), and no field here carries it. ``extra="forbid"`` for the reason the mint forbids extras (#2587): a producer sending a field this service does not take is told which one rather than having it read and dropped in silence. Everything is bounded because the body is an anonymous browser's, and a report that could carry an unbounded list would be an unbounded write to the span store.
  - `playback_stalls_ms` integer[]
  - `playback_stalls` PlaybackStall[]
    - `at_ms` integer, required
    - `ms` integer, required
  - `buffer_drops` integer
  - `clipping_ratio` number
  - `client_fault` 'none' | 'audioworklet_crash' | 'microphone_revoked' | 'playback_stalled' | 'transport_timeout'
  - `echo_window_frames` integer
  - `echo_window_voiced` integer

## Response `204`

Successful Response

## Other responses

- `422`: Validation Error

## Changes

- **2026-10-16** `659a98611b11`: 2 info
  - added the new optional request property `echo_window_frames`
  - added the new optional request property `echo_window_voiced`
- **2026-09-30** `4d6094c01248`: 1 info
  - added the new optional request property `playback_stalls`
- **2026-09-22** `948fadc541cf`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/imperfect/apis/imperfect-api/changes/goals/voice/session/:session_id/playback/post.md)

---

[API](https://skmtc.dev/imperfect/apis/imperfect-api.md) - [All operations](https://skmtc.dev/imperfect/apis/imperfect-api/llms.txt) - [OpenAPI document](https://skmtc.dev/imperfect/apis/imperfect-api/revisions/33a8821ec7f8?raw)
