REST APIStatus

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>/status

GET /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>/job

GET /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

EventWhen it fires
release.status_changedThe 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.approvedOnce, 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 example release.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/webhooks

Register 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.