Status
After you submit a release, monitor its progress with two endpoints: one for store delivery and one for the background processing job. Both require authentication.
Delivery & aggregate status
curl -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
https://once.app/v1/releases/<id>/statusGET /v1/releases/:id/status returns per-store delivery state plus an
aggregate status for the release. Poll this after submission to follow the
release out to the stores.
A release that has not reached the distributor yet returns a queued response
(aggregateStatus: "queued" with pending: true) instead of an empty result.
Errors are specific: a malformed release id, a release that does not exist,
and a release owned by another account each return a distinct error message,
so your integration can react correctly instead of treating every failure as
forbidden. When live store statuses cannot be fetched, the response falls back
to the last known statuses and includes fallback: true plus a
fallbackReason explaining why.
Processing job
curl -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
https://once.app/v1/releases/<id>/jobGET /v1/releases/:id/job returns the background processing job’s state and
any errors. Use it to diagnose a release that is stuck or failed before it
reaches the delivery stage.
Release status webhooks
Instead of polling, register webhook endpoints and let ONCE push status changes to you. Webhooks are the recommended pattern; use polling as a fallback or for on-demand checks.
Register up to 5 https endpoints, either in Settings > Developer in
the ONCE app or over the REST API. Each
endpoint gets a signing secret that is shown once at creation, so copy it
immediately.
Events
| Event | When it fires |
|---|---|
release.status_changed | The release’s aggregate distribution status moves (for example pending → delivered), or an individual store goes live or is taken down while the aggregate stays put (for example a store you added later finishing delivery, or a partial takedown completing). |
release.approved | Once, when the release passes distributor approval and goes live. Carries the final UPC and every track ISRC. |
New endpoints subscribe to every event. An endpoint keeps the list it was
created with, so an endpoint registered before an event existed has to opt in
with a PATCH:
curl -X PATCH https://once.app/v1/webhooks/<id> \
-H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"event_types": ["release.status_changed", "release.approved"]}'Payloads
On every release status change, ONCE sends a POST to each active endpoint
with the event release.status_changed:
{
"event": "release.status_changed",
"releaseId": "uuid",
"title": "My Release",
"artist": "Artist Name",
"previousStatus": "delivered",
"status": "distributed",
"storeStatuses": [
{
"storeId": 9,
"storeName": "Spotify",
"statusText": "Live",
"urlInStore": "https://open.spotify.com/album/..."
}
],
"artistProfiles": [
{
"name": "Artist Name",
"isPrimary": true,
"appleMusicArtistId": "1514763519",
"appleMusicArtistUrl": "https://music.apple.com/artist/1514763519",
"spotifyArtistId": "3TVXtAsR1Inumwj472S9r4",
"spotifyArtistUri": "spotify:artist:3TVXtAsR1Inumwj472S9r4",
"spotifyArtistUrl": "https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4"
}
],
"occurredAt": "2026-07-04T12:00:00.000Z"
}release.status_changed also fires when a store’s own status changes without
moving the aggregate. You can tell the two apart: previousStatus and
status are equal, and storeStatuses lists only the stores that changed
rather than every store. Two cases trigger it.
A store you added to an already-distributed release finishes delivery:
{
"event": "release.status_changed",
"releaseId": "uuid",
"title": "My Release",
"artist": "Artist Name",
"previousStatus": "distributed",
"status": "distributed",
"storeStatuses": [
{
"storeId": 319,
"storeName": "TikTok",
"statusText": "Delivered to DSP"
}
],
"artistProfiles": [],
"occurredAt": "2026-07-04T12:00:00.000Z"
}A store confirms a takedown you requested (a full takedown that empties the
release moves the aggregate to taken_down instead, and arrives as the
aggregate shape above):
{
"event": "release.status_changed",
"releaseId": "uuid",
"title": "My Release",
"artist": "Artist Name",
"previousStatus": "distributed",
"status": "distributed",
"storeStatuses": [
{
"storeId": 319,
"storeName": "TikTok",
"statusText": "Takedown delivered"
}
],
"artistProfiles": [],
"occurredAt": "2026-07-04T12:00:00.000Z"
}Nothing is sent while an added store is still queued or in inspection, or while a takedown is still pending at the store. A takedown spread over several stores can arrive as more than one event, as each store confirms.
Artist identifiers
Every payload carries artistProfiles, the store artist profiles pinned to
the release, so you can attach it to the right artist instead of matching by
name. The main primary artist comes first with isPrimary: true and is always
present — null identifiers mean no profile is linked yet, which is the
signal to ask for one. Co-primary artists on a collaboration follow, and
appear once they carry an identifier of their own.
Spotify is given as both a URI (spotify:artist:<id>) and an https URL; Apple
Music has no URI form, so it is the numeric artist id plus its catalog URL.
Headers, delivery and retries
Every delivery carries two headers:
x-once-event: the event type (for examplerelease.status_changed)x-once-signature:sha256=<hex>, the HMAC-SHA256 of the raw request body computed with your endpoint’s signing secret
Verify each delivery by computing the HMAC-SHA256 of the raw body with your
secret and comparing it to the hex digest in x-once-signature.
Delivery and retries: failed deliveries are retried twice. An endpoint
that fails 20 deliveries in a row is disabled automatically; you can re-enable
it from Settings > Developer, or with a PATCH request, once it is healthy
again. An identical event for the same release is not re-sent to an endpoint
that already received it in the last 6 hours.
Manage webhooks over the API
Headless integrations can manage webhook endpoints directly with a bearer token (PAT or OAuth), with the same validation and limits as the in-app UI.
List your endpoints (signing secrets are never returned):
curl -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
https://once.app/v1/webhooksRegister an endpoint. The response includes the signing secret exactly once, so store it immediately:
curl -X POST https://once.app/v1/webhooks \
-H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.com/hooks/once", "description": "Production listener"}'Disable an endpoint, or re-enable one that was auto-disabled after repeated
failures (re-enabling resets the failure count). The same request takes
event_types to change which events the endpoint receives:
curl -X PATCH https://once.app/v1/webhooks/<id> \
-H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"active": true}'Delete an endpoint:
curl -X DELETE https://once.app/v1/webhooks/<id> \
-H "Authorization: Bearer $ONCE_ACCESS_TOKEN"A POST beyond the 5-endpoint limit returns 409 conflict_error, a
non-https URL returns 400 invalid_request_error, and an endpoint id that
does not belong to your account returns 404 not_found_error.
Polling guidance
Status moves through asynchronous stages, so poll on an interval rather than
expecting an immediate final state. Respect the Retry-After header if you
receive a 429 rate_limit_error, and treat a 502 upstream_error as
transient, so retry with backoff.
Tip: Prefer webhooks over polling when you need to react to status changes. Polling remains fully supported for on-demand checks.