Submit listens.
Accepts a session cookie or any valid token — this is the one endpoint a
submit-scoped token reaches.
listen_type decides forwarding, and getting it wrong rewrites
someone's permanent public history. single and playing_now are live
and will be forwarded.
import is stored only and not forwarded (since v0.26 — before
that, an import was forwarded like a live listen) — unless the token
used to submit it is marked imports_are_live, which exists because
some players label every scrobble import and nothing on the wire
distinguishes that from a backfill. Even then two gates apply and both
must pass:
- the listen is no more than 24h old, so a genuine history import from the same client still stays local; and
- its timestamp is no more than 1h in the future, so ordinary clock skew is tolerated but a broken clock cannot park a scrobble years ahead of everything else.
The flag is per token and invisible from the client side, which is why
GET /1/validate-token reports it — read it there rather than guessing
from whether listens appear at the far end.
Dedup is on (user_id, source_id, source_name) plus a fuzzy window, so
re-sending the same listen is safe. Max 1000 listens per request.
additional_info is free-form and no field in it can fail a
submission: an unusable value costs that field and the listen is still
stored. Unknown keys are ignored.
Parameters
dry_run query Resolve everything, store nothing and forward nothing, and report
what would have happened. 1, true, yes, on and a bare
?dry_run all count.
This is the endpoint to point a client at while it is being set up: it names the winning rung of the chain ladder, the quality score your fields produced, whether the listen would be forwarded or stored only, and whether dedup would swallow it — none of which is visible from a successful submit. The preview and the real path share one implementation, so it cannot promise something the real path would not do.
Note it is genuinely read-only: a dry run looks a device up rather than upserting it, because asking what would happen must not itself be a listen.
Request body required
application/json
A ListenBrainz-shaped submission.
listen_type string required This decides forwarding. single and playing_now are live;
import is stored only unless the token is marked imports_are_live —
see POST /1/submit-listens.
singleplaying_nowimportpayload array required At least one listen and at most 1000; exactly one for playing_now.
One listen in a ListenBrainz-shaped submission.
listened_at integer | null · int64 Unix seconds, UTC, start of play. Required for single and
import, and ignored for playing_now, which is always now.
track_metadata object required artist_name string required The artist credit as one string. A semicolon-separated value
(Bach; Hilary Hahn) is read as several credited artists when
additional_info.artist_names is absent — see there.
track_name string required release_name string | null additional_info object | null ListenBrainz's free-form bag, and where Tapedeck's own extensions ride.
No field in here can fail a submission. A value of the wrong type
costs that field — or, for a tapedeck_* object, that object — and the
listen is still stored. Unknown keys are ignored.
submission_client string | null submission_client_version string | null duration_ms integer | string | null isrc string | null The recording's ISRC, when the client knows it — a tag read off the file usually does. It survives crossing between a library, a streaming catalogue and MusicBrainz, so a listen that arrives with one never needs a lookup to be identified.
track_number integer | string | null skipped boolean | null The track was skipped rather than played through. Stored, excluded
from every count, and never forwarded — a forwarded skip is a wrong
scrobble on a permanent record. Top level of additional_info; there
is no tapedeck_skipped.
listened_ms integer | string | null recording_mbid string | null MBIDs as a submitting client sends them. A listen that arrives identified is never looked up again.
release_mbid string | null release_group_mbid string | null The release group, which is what relates a reissue, a remaster and a regional edition to one another.
release_track_mbid string | null The track on a particular release, as distinct from the recording. Only a tagging client ever knows it.
work_mbid string | null The composition rather than a performance of it. Only a tagging client ever knows it.
artist_mbids array | null album_artist_mbids array | null Whoever the record is credited to. On a compilation or a soundtrack
that is not the performer, which is why it is a field of its own rather
than a fallback for artist_mbids.
artist_names array | null Everyone credited, where the client can separate them — the only structured way to say "Bach and Hilary Hahn". All of them are recorded and the artist pages and charts aggregate over every one, so a soloist appears under their own name rather than vanishing into the composer's.
Failing this, a semicolon-separated artist_name is split on ;
and nothing else. & and , appear inside real names — "Simon &
Garfunkel", "Earth, Wind & Fire" — so splitting on those would invent
artists who do not exist.
tapedeck_audio object | null How the audio actually sounded, as a submitting client reports it in
additional_info.tapedeck_audio. Every field is optional; a plugin that
can see its decoder reports most, a bare scrobble client none.
This is the write-side shape. A stored listen carries the same information
flat on the Listen object rather than nested.
format_type string | null pcm, dsd, or mqa. Not the codec.
codec string | null The codec as your decoder names it — flac, mp3, dsd_lsbf_planar.
Tapedeck matches DSD and PCM by prefix, so ffmpeg's per-ordering and
per-sample-format spellings all classify correctly.
bitrate integer | null · int32 kbps.
sample_rate integer | null · int32 Hz.
bit_depth integer | null · int32 channels integer | null · int32 container string | null The file container, e.g. ogg, m4a.
is_lossless boolean | null source_quality string | null What the source claims to be, where that differs from what was measured.
dsd_rate integer | null · int64 Hz.
dsd_multiplier integer | null · int32 64, 128, 256… Derived from dsd_rate when absent, and left unset rather
than guessed when the rate is not a real DSD multiple.
delivery_codec string | null Set only when the audio was genuinely transcoded. Remuxing a container without touching the audio is not a transcode, nor is a video transcode that left the audio alone. Scoring docks points whenever this is set, so filling it on a direct play quietly penalises the best listens on the system.
delivery_bitrate integer | null · int32 kbps.
delivery_sample_rate integer | null · int32 Hz.
delivery_bit_depth integer | null · int32 dsd_to_pcm_converted boolean | null Usually derived from the signal chain rather than reported. SACD's DSD layer cannot leave a player over coax or optical, so nothing downstream will ever tell you this directly.
is_transcoded boolean | null transcode_reason string | null tapedeck_device object | null What was playing it. Resolves to a devices row, which is rung 4 of the
chain ladder — without it a listen from a client with no token default and
no output binding gets no signal chain at all.
player_name string | null player_version string | null platform string | null machine_id string | null The key. Stable per installation and never the display name, which the user edits — keying on a name re-registers the device as a new one the moment it is renamed. Nothing is recorded without this.
output_device string | null What the audio came out of. Recorded whether or not it is mapped, so the UI can offer unmapped ones for one-tap assignment, and it drives rung 2 of the chain ladder.
output_type string | null interface string | null USB, Bluetooth, analog… Preferred over output_type.
tapedeck_chain object | null Signal chain, rung 1 of the four-rung ladder: explicit name →
output-device binding → the submitting token's default_chain_id → the
source device's default. An unknown name resolves to no chain rather than
erroring.
Note this is an object. It was documented as a bare string until v0.66.0 and never was one.
chain_id string | null The chain's name, despite the field being called chain_id — a
client knows what the user called it, not its row id. Use ?dry_run=1
to check it resolves.
components array | null Accepted and not yet stored.
tapedeck_session object | null What the client was doing when the track came up — specifically, whether the listener chose it.
is_shuffle boolean | null Whether shuffle chose this track rather than the listener.
queue_source string | null What it was played from — a playlist name, an album, a queue, radio. Free text: it is your vocabulary for your own containers and there is no shared one to normalise against.
tapedeck_playback object | null Where the playhead is, from a client that can actually see it.
The ListenBrainz submission format carries no position, so a playing_now
without this is wall-clock arithmetic from the moment you spoke — right
until the listener pauses or seeks, wrong from then on, and reported as an
estimate (position_known: false) rather than dressed up as a measurement.
Send this and the deck shows a real position.
A playhead sent here is believed outright, including a step backwards: a polled source's report may lag its player by a few seconds and is smoothed for it, but a client measuring at the instant it sends has no such lag, so a backward step is the listener seeking and you are the authority on that.
position_ms integer | string | null state string | null playing or paused. Anything other than paused is read as playing,
which is the safe way to fall: a wrongly-paused deck freezes and reads
as broken.
Send paused when the listener pauses. Without it the position
counts forward through the pause while position_known asserts it is a
measurement — a confident and completely wrong readout. A paused entry
holds still and expires after ten minutes of silence, so a heartbeat
keeps it alive and stopping altogether clears it.
mbid_mapping object | null The shape ListenBrainz returns its MBIDs in. Read as a fallback;
a submitting client should put them in additional_info.
recording_mbid string | null release_mbid string | null artist_mbids array | null caa_id integer | null · int64 Cover Art Archive id.
caa_release_mbid string | null Responses
Accepted, or — with ?dry_run — resolved and discarded. Per-listen results are reported rather than the whole batch failing: one bad row must not lose the other 999.
application/json
A submit answers one of these: the outcome, or — with ?dry_run — the
preview.
option 1 object Everything past status is a Tapedeck extension and purely additive —
ListenBrainz clients branch on status alone, so the extra keys cannot
break them. They exist because a batch submit was otherwise all-or-nothing
from the client's side: "ok" said nothing about which listens were stored,
which were already held, and which were malformed.
status string required Always ok.
accepted integer · int32 required Newly stored.
duplicate integer · int32 required Singular. Recognised as already held, by source_id or by the
fuzzy window. Not an error — a client resending after a dropped
connection is the normal way this happens. But a client whose every
listen comes back here has a bug, which is why it is counted apart from
accepted rather than folded in.
rejected array required Could not be stored and must not be retried — malformed client data, so resending changes nothing. Always present, empty when nothing was rejected; an absent key would be ambiguous.
index integer required Index into the submitted payload, so the client can map it back to
what it sent.
reason string required option 2 object The reply to ?dry_run — what would have happened.
status string required Always ok.
dry_run boolean required Always true.
listens array required One listen's resolved state, computed and thrown away.
index integer required Index into the submitted payload.
artist string required title string required album string | null timestamp integer · int64 required quality_score number | null · double What your tapedeck_audio fields scored, 0–100 — the main thing worth
getting right and otherwise invisible.
chain_id integer | null · int64 chain_name string | null chain_source string required Which rung of the ladder won: explicit, output_binding,
token_default, device_default or none. The single most useful
field here: a chain arriving from the wrong rung looks identical to one
arriving from the right one, so without this a misconfiguration is
invisible until someone reads their history months later.
device_id integer | null · int64 listening_context string | null status_if_stored string required pending will be forwarded onward; imported is stored only. The
distinction a backfill has to get right, answered before it is too late
to change.
skipped boolean required duplicate boolean required Whether dedup would have swallowed it, checked read-only against the real rules.
rejected array required index integer required Index into the submitted payload, so the client can map it back to
what it sent.
reason string required Malformed or rejected input.
application/json
Every error body in the API has this shape.
code integer · int32 required Mirrors the HTTP status.
error string required Human-readable. Not a stable identifier — do not branch on it.
No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.
application/json
Every error body in the API has this shape.
code integer · int32 required Mirrors the HTTP status.
error string required Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}One or more listens could not be stored. Retryable — resend the batch; dedup makes that safe. Distinct from rejected, which is the do-not-retry list.
application/json
Every error body in the API has this shape.
code integer · int32 required Mirrors the HTTP status.
error string required Human-readable. Not a stable identifier — do not branch on it.