Webhooks
Status changes pushed to your server, instead of polled for
Overview
Register an HTTPS URL, choose the event types you want, and the API sends that URL one signed
POST per event: an album changing status, a distribution request moving
forward, a delivery being created, an analytics report finishing. You only receive events about records your
account can reach through the API — the same records you can read with a GET.
Webhooks tell you that something changed. The payload is deliberately small: fetch the record through the API when you need its full state.
/webhook-endpoints/webhook-events
Getting started
- Expose an HTTPS URL on your server that accepts
POSTrequests. - Register it with the event types you want, and store the
signing-secretfrom the response. It is shown once. - Send a test event and check that your server verifies its signature.
- Answer every delivery with a
2xxquickly, and process it afterwards.
The Webhook Endpoint Resource
| Attribute | Type | Description |
|---|---|---|
| url | string | Where events are sent. Writable on create and update. See URL rules |
| events | array | Event types this endpoint receives. At least one, no repeats, each one from the event catalog. Writable on create and update |
| description | string | null | Free text for your own reference, up to 255 characters. Writable on create and update |
| is-active | boolean | Whether the endpoint receives events. Writable on update only. Set to false by the API after repeated failures, see automatic disabling |
| disabled-at | string | null | When the endpoint was disabled, by you or by the API. null while active. Read only |
| consecutive-failures | integer | Failed deliveries in a row. Any successful delivery resets it to 0. Read only |
| created-at | string | ISO 8601 date-time. Read only |
| updated-at | string | ISO 8601 date-time. Read only |
| signing-secret | string | Present only in the responses of create and rotate secret. Never readable again |
- • Sending any other attribute (for example
signing-secretorconsecutive-failures) answers422: it is refused, not ignored. - • Every route acts on your endpoints. The id of an endpoint that belongs to someone else answers
404, exactly like an id that does not exist.
URL rules
- •
httpsonly, as an absolute URL of up to 2048 characters. - • No credentials in the URL: a user name or password before the host is refused.
- • The host must resolve, and only to public addresses: loopback, private, link-local and other reserved ranges are refused.
- • Redirects are not followed. A
3xxanswer counts as a failed attempt, so register the final URL. - • The URL is checked when you register it, when you change it, and again before every delivery.
{
"jsonapi": { "version": "1.0" },
"errors": [
{
"status": "422",
"title": "Unprocessable Entity",
"detail": "URL refused: only https is allowed.",
"source": { "pointer": "/data/attributes/url" }
}
]
}
List Your Endpoints
/webhook-endpoints
GET /webhook-endpoints
Authorization: Bearer {token}
Accept: application/vnd.api+json
Returns all your endpoints, active and disabled. Not paginated: there are at most 5. Deleted endpoints are not listed.
{
"jsonapi": { "version": "1.0" },
"data": [
{
"type": "webhook-endpoints",
"id": "12",
"attributes": {
"url": "https://example.com/webhooks/limbo",
"events": ["album.status_changed", "delivery.created"],
"description": "Production listener",
"is-active": true,
"disabled-at": null,
"consecutive-failures": 0,
"created-at": "2026-10-02T12:00:00.000000Z",
"updated-at": "2026-10-02T12:00:00.000000Z"
},
"links": { "self": "https://domain.com/api/v1/webhook-endpoints/12" }
}
]
}
Get One Endpoint
/webhook-endpoints/{id}
GET /webhook-endpoints/12
Authorization: Bearer {token}
Accept: application/vnd.api+json
Answers 200 OK with the same resource object as the list, under data.
The signing-secret is not included.
Register an Endpoint
/webhook-endpoints
POST /webhook-endpoints
Authorization: Bearer {token}
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
{
"data": {
"type": "webhook-endpoints",
"attributes": {
"url": "https://example.com/webhooks/limbo",
"events": ["album.status_changed", "delivery.created"],
"description": "Production listener"
}
}
}
- •
urlandeventsare required,descriptionis optional.is-activecannot be sent on create: a new endpoint is always active. - • You may hold up to 5 endpoints at once. Deleted endpoints do not count.
- • A client-generated
data.id, or adata.typeother thanwebhook-endpoints, answers409.
{
"jsonapi": { "version": "1.0" },
"data": {
"type": "webhook-endpoints",
"id": "12",
"attributes": {
"url": "https://example.com/webhooks/limbo",
"events": ["album.status_changed", "delivery.created"],
"description": "Production listener",
"is-active": true,
"disabled-at": null,
"consecutive-failures": 0,
"created-at": "2026-10-02T12:00:00.000000Z",
"updated-at": "2026-10-02T12:00:00.000000Z",
"signing-secret": "whsec_3f9a1c0e7b2d4a6f8e1c3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f4a6c8e0b1d3f5a"
},
"links": { "self": "https://domain.com/api/v1/webhook-endpoints/12" }
}
}
The response carries a Location header with the endpoint's URL.
Store the signing-secret now. It is a credential, it appears in this
response and nowhere else, and it cannot be read back later. If you lose it, rotate it.
The secret is whsec_ followed by 64 hexadecimal characters.
Limit reached
{
"jsonapi": { "version": "1.0" },
"errors": [
{
"status": "422",
"code": "webhook-endpoint-limit",
"title": "Unprocessable Entity",
"detail": "Endpoint limit reached: 5 per user. Delete one before registering another."
}
]
}
Update an Endpoint
/webhook-endpoints/{id}
PATCH /webhook-endpoints/12
Authorization: Bearer {token}
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
{
"data": {
"type": "webhook-endpoints",
"id": "12",
"attributes": {
"events": ["album.status_changed", "distribution_request.status_changed"]
}
}
}
- • Send only the attributes you change:
url,events,description,is-active.eventsreplaces the whole list. - •
"is-active": falsestops deliveries to the endpoint. - •
"is-active": truere-enables it and resetsconsecutive-failuresto 0. - • A
data.idthat differs from the id in the path answers409. - • Answers 200 OK with the updated resource, without the secret.
Delete an Endpoint
/webhook-endpoints/{id}
DELETE /webhook-endpoints/12
Authorization: Bearer {token}
Accept: application/vnd.api+json
- • Answers 204 No Content. The endpoint stops receiving events at once, including retries still pending.
- • Its event history is no longer listed in
/webhook-events, and it frees one slot of your limit.
Rotate the Signing Secret
/webhook-endpoints/{id}/rotate-secret
POST /webhook-endpoints/12/rotate-secret
Authorization: Bearer {token}
Accept: application/vnd.api+json
No body. Answers 200 OK with the endpoint resource carrying the new
signing-secret, shown once, exactly like on create.
There is no overlap window. The previous secret stops being used immediately: every delivery from this moment on, retries of earlier events included, is signed with the new one. Update your server with the new secret right away.
Send a Test Event
/webhook-endpoints/{id}/test
POST /webhook-endpoints/12/test
Authorization: Bearer {token}
Accept: application/vnd.api+json
Schedules one webhook.test event for this endpoint only, signed like any other
event, and answers 202 Accepted with the scheduled event. The POST
to your URL follows within seconds. Use it to wire up signature verification without waiting for a real event.
{
"jsonapi": { "version": "1.0" },
"data": {
"type": "webhook-events",
"id": "evt_6f1c2a9e-4b7d-4c3e-9a1f-2d8b5e0c7a14",
"attributes": {
"type": "webhook.test",
"created-at": "2026-10-02T12:00:00.000000Z",
"data": {
"webhook_endpoint": { "id": 12 },
"message": "Test event sent from the Limbo API."
},
"deliveries": [
{
"endpoint-id": "12",
"status": "pending",
"attempts": 0,
"last-response-status": null,
"delivered-at": null
}
]
},
"links": { "self": "https://domain.com/api/v1/webhook-events/{id}" }
}
}
Endpoint disabled
{
"jsonapi": { "version": "1.0" },
"errors": [
{
"status": "409",
"code": "webhook-endpoint-disabled",
"title": "Conflict",
"detail": "This endpoint is disabled. Re-enable it (PATCH is-active: true) before sending a test event."
}
]
}
List Sent Events
/webhook-events
GET /webhook-events?filter[type]=delivery.created&filter[created-at-gte]=2026-10-01
Authorization: Bearer {token}
Accept: application/vnd.api+json
The events addressed to your endpoints, newest first, each with the state of its delivery to each of your endpoints. Use it to see what your server received, and to catch up on events it missed.
Event history is kept for 30 days: an event, and the record of its deliveries, is no longer listed once it is older than that and its delivery has finished. An endpoint only receives events that happen after it was created.
| Parameter | Format | Description |
|---|---|---|
filter[type] |
string | One event type from the catalog, or webhook.test |
filter[created-at-gte] |
date or ISO 8601 | Events created at or after this moment |
filter[created-at-lte] |
date or ISO 8601 | Events created at or before this moment. A bare date (2026-10-01) covers that whole day |
page[number] |
integer | Page to return, from 1 |
page[size] |
integer | Events per page. Default 20, maximum 100 |
- • Any other filter answers
422, as does a malformed value: a filter is never silently ignored. - • Only events addressed to one of your current endpoints are listed. Events that happened while an endpoint was disabled were not addressed to it and do not appear here.
- •
deliverieslists your endpoints only. Itsstatusispending(not delivered yet, retries may follow),succeededorfailed(retries exhausted).
{
"jsonapi": { "version": "1.0" },
"meta": {
"page": { "currentPage": 1, "from": 1, "lastPage": 1, "perPage": 20, "to": 1, "total": 1 }
},
"links": {
"first": "https://domain.com/api/v1/webhook-events?page%5Bnumber%5D=1&page%5Bsize%5D=20",
"last": "https://domain.com/api/v1/webhook-events?page%5Bnumber%5D=1&page%5Bsize%5D=20"
},
"data": [
{
"type": "webhook-events",
"id": "evt_0b8e4d2a-1c3f-4e5a-8b7d-9f6a2c1e3d40",
"attributes": {
"type": "delivery.created",
"created-at": "2026-10-02T12:00:00.000000Z",
"data": {
"delivery": {
"id": 9001,
"album_id": 123,
"dsp": { "id": 1, "name": "Spotify" },
"type": "delivery",
"status": "sent",
"errors": null
}
},
"deliveries": [
{
"endpoint-id": "12",
"status": "succeeded",
"attempts": 1,
"last-response-status": 200,
"delivered-at": "2026-10-02T12:00:04.000000Z"
}
]
},
"links": { "self": "https://domain.com/api/v1/webhook-events/{id}" }
}
]
}
Get one event
/webhook-events/{id}
GET /webhook-events/{id}
Authorization: Bearer {token}
Accept: application/vnd.api+json
{id} is the evt_… value your server received (for example
evt_0b8e4d2a-1c3f-4e5a-8b7d-9f6a2c1e3d40) in the body and in the
Limbo-Event-Id header. Answers 200 OK with one resource
object; an event that was not addressed to you answers 404.
Event Catalog (v1)
The data object of each type is shown below. Statuses travel as text keys, as the
API returns them elsewhere. Ids are integers you can use directly with the corresponding API resource.
| Type | Sent when |
|---|---|
album.status_changed |
An album moves to a different status |
distribution_request.status_changed |
A distribution request is created, or changes status |
delivery.created |
A delivery of an album to a store is created |
analytics_report.completed |
An extended analytics report you requested is ready |
analytics_report.failed |
An extended analytics report you requested could not be produced |
webhook.test |
You asked for a test event. Not subscribable: it is only sent on request |
album.status_changed
{
"album": { "id": 123, "upc": "8400000000017", "title": "The Album" },
"previous_status": "in_review",
"status": "approved"
}
- •
statusandprevious_statusare the album status keys, the same values as the album'slast-statusin Albums (for examplein_review,approved,delivered). - •
upcisnullwhile the album has none.
distribution_request.status_changed
{
"distribution_request": { "id": 55, "type": "delivery", "album_id": 123 },
"previous_status": "pending",
"status": "sent"
}
- •
statusis one ofpending,preapproved,rejected,sent,error. - • When the request is created,
previous_statusisnull. - •
typeis the request type:delivery,update,redeliveryortakedown. See Distribution Requests.
delivery.created
{
"delivery": {
"id": 9001,
"album_id": 123,
"dsp": { "id": 1, "name": "Spotify" },
"type": "delivery",
"status": "sent",
"errors": null
}
}
- • One event per store: an album sent to five stores produces five events.
- •
statusissentorerror, with the reasons inerrorswhen there are any. See Deliveries.
sent means the delivery was accepted for processing, not that the
release is live in the store. Each store publishes on its own schedule.
analytics_report.completed / analytics_report.failed
{
"analytics_report": {
"id": 7,
"dsp_id": 1,
"date": "2026-09-30",
"status": "completed",
"total_records": 4210
}
}
- •
statusiscompletedorfailed, matching the event type. On failuretotal_recordsisnull. - • The payload carries no download link: fetch the report with
GET /analytics-reports/{id}to get it. See Extended Analytics Reports.
webhook.test
{
"webhook_endpoint": { "id": 12 },
"message": "Test event sent from the Limbo API."
}
What Your Server Receives
POST https://example.com/webhooks/limbo
Content-Type: application/json
User-Agent: Limbo-Webhooks/1.0
Limbo-Event-Id: evt_0b8e4d2a-1c3f-4e5a-8b7d-9f6a2c1e3d40
Limbo-Signature: t=1790942404,v1=5d41402abc4b2a76b9719d911017c592ae2f3c7b1e0d9a8f6c5b4a3d2e1f0a9b
{"id":"evt_0b8e4d2a-1c3f-4e5a-8b7d-9f6a2c1e3d40","type":"delivery.created","created_at":"2026-10-02T12:00:00Z","data":{"delivery":{"id":9001,"album_id":123,"dsp":{"id":1,"name":"Spotify"},"type":"delivery","status":"sent","errors":null}}}
| Body field | Description |
|---|---|
| id | Unique event id, evt_ followed by a UUID. The same value as the Limbo-Event-Id header. Use it to deduplicate |
| type | Event type from the catalog |
| created_at | When the change happened, ISO 8601 in UTC |
| data | The payload of that type |
How to answer
- • Answer any
2xxto acknowledge the event. Anything else, a redirect included, is a failed attempt and will be retried. - • Answer quickly: an attempt that takes longer than 10 seconds fails. Acknowledge first, then do the work in the background.
- • The same event can arrive more than once. Deduplicate by
id. - • Events are not guaranteed to arrive in order. Compare
created_at, or fetch the current state of the record from the API, before acting on a status.
Verifying the Signature
Every delivery carries Limbo-Signature: t=<unix seconds>,v1=<hex>, where
v1 is the HMAC-SHA256 of the string "{t}.{raw body}",
keyed with your endpoint's whole signing-secret (the whsec_ prefix included).
- Read the raw request body, byte for byte. Do not parse and re-encode the JSON first: the signature would no longer match.
- Split the header on
,and taketandv1. - Compute the HMAC-SHA256 of
t + "." + raw bodywith your secret, as lowercase hex. - Compare it with
v1using a constant-time comparison. - Reject the request if
tis too far from your current time (5 minutes is a sensible tolerance). Each attempt, retries included, is signed at the moment it is sent, so a genuine delivery is never old.
PHP
function limboWebhookIsValid(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[trim($key)] = trim($value);
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_LIMBO_SIGNATURE'] ?? '';
if (!limboWebhookIsValid($rawBody, $header, getenv('LIMBO_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(204); // acknowledge, then process $event['id'] once
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
function limboWebhookIsValid(rawBody, header, secret, tolerance = 300) {
const parts = Object.fromEntries(
(header || '').split(',').map((pair) => {
const i = pair.indexOf('=');
return [pair.slice(0, i).trim(), pair.slice(i + 1).trim()];
})
);
if (!/^\d+$/.test(parts.t || '') || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody) // the raw Buffer, not re-serialized JSON
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/webhooks/limbo', express.raw({ type: 'application/json' }), (req, res) => {
if (!limboWebhookIsValid(req.body, req.get('Limbo-Signature'), process.env.LIMBO_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204); // acknowledge, then process event.id once
});
Retries
A delivery that fails is retried on a schedule: 10 attempts in total, spread over about
3.9 days. After the last one the delivery is marked failed in
/webhook-events.
| Attempt | Wait after the previous one | Time since the event |
|---|---|---|
| 1 | — | a few seconds |
| 2 | 1m | 1m |
| 3 | 5m | 6m |
| 4 | 30m | 36m |
| 5 | 2h | 2h 36m |
| 6 | 6h | 8h 36m |
| 7 | 12h | 20h 36m |
| 8 | 24h | 1.9 days |
| 9 | 24h | 2.9 days |
| 10 | 24h | 3.9 days |
- • The waits in the table are nominal: each one is varied at random by up to ±20%, so deliveries that failed together are not all retried at the same moment.
- • If your server answers
429or503with aRetry-Afterheader (in seconds or as an HTTP date), the next attempt waits at least that long, up to 24h. It is never sent sooner than the schedule, and it still counts as one of the 10 attempts.
Automatic disabling
- • After 5 failed deliveries in a row (each one with its retries exhausted), the endpoint is disabled:
is-activebecomesfalseanddisabled-atis set. - • Any successful delivery resets
consecutive-failuresto 0. - • While an endpoint is disabled it receives nothing: retries still pending are dropped and new events are not sent to it.
- • Fix your server, then re-enable the endpoint with
PATCH"is-active": true. A test event confirms it works.
Catching up
- • Deliveries that failed are listed in
GET /webhook-events, with their payload. Filter byfilter[created-at-gte]from the moment your server went down; the history covers the last 30 days. - • Events that happened while the endpoint was disabled were never addressed to it and are not listed. To resynchronise that window, read the current state through the regular resources (Albums, Distribution Requests, Deliveries, Extended Analytics Reports).
Errors
| Status | Code | When |
|---|---|---|
| 401 | - | The request carries no valid bearer token |
| 404 | - | The endpoint or event does not exist, or is not yours (the answer is the same) |
| 409 | webhook-endpoint-disabled |
A test event was requested for a disabled endpoint |
| 409 | - | data.type is not webhook-endpoints, a data.id was sent on create, or it differs from the path on update |
| 422 | webhook-endpoint-limit |
You already hold 5 endpoints |
| 422 | - | A refused URL, an unknown or repeated event type, an empty events, an attribute that is not writable, or an unknown or malformed filter. source.pointer names the field |