Skip to content

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.

Endpoints
/webhook-endpoints
/webhook-events
Access Level
Self-service: each user manages only their own endpoints
Limit
5 endpoints per user

Getting started

  1. Expose an HTTPS URL on your server that accepts POST requests.
  2. Register it with the event types you want, and store the signing-secret from the response. It is shown once.
  3. Send a test event and check that your server verifies its signature.
  4. Answer every delivery with a 2xx quickly, 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-secret or consecutive-failures) answers 422: 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

  • • https only, 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 3xx answer 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

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

200 OK
{
  "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

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

POST /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"
    }
  }
}
  • • url and events are required, description is optional. is-active cannot 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 a data.type other than webhook-endpoints, answers 409.
201 Created
{
  "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

422 Unprocessable Entity
{
  "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

PATCH /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. events replaces the whole list.
  • • "is-active": false stops deliveries to the endpoint.
  • • "is-active": true re-enables it and resets consecutive-failures to 0.
  • • A data.id that differs from the id in the path answers 409.
  • • Answers 200 OK with the updated resource, without the secret.

Delete an Endpoint

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

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

POST /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.

202 Accepted
{
  "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

409 Conflict
{
  "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

GET /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.
  • • deliveries lists your endpoints only. Its status is pending (not delivered yet, retries may follow), succeeded or failed (retries exhausted).
200 OK
{
  "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

GET /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"
}
  • • status and previous_status are the album status keys, the same values as the album's last-status in Albums (for example in_review, approved, delivered).
  • • upc is null while the album has none.

distribution_request.status_changed

{
  "distribution_request": { "id": 55, "type": "delivery", "album_id": 123 },
  "previous_status": "pending",
  "status": "sent"
}
  • • status is one of pending, preapproved, rejected, sent, error.
  • • When the request is created, previous_status is null.
  • • type is the request type: delivery, update, redelivery or takedown. 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.
  • • status is sent or error, with the reasons in errors when 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
  }
}
  • • status is completed or failed, matching the event type. On failure total_records is null.
  • • 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 2xx to 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).

  1. Read the raw request body, byte for byte. Do not parse and re-encode the JSON first: the signature would no longer match.
  2. Split the header on , and take t and v1.
  3. Compute the HMAC-SHA256 of t + "." + raw body with your secret, as lowercase hex.
  4. Compare it with v1 using a constant-time comparison.
  5. Reject the request if t is 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 429 or 503 with a Retry-After header (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-active becomes false and disabled-at is set.
  • • Any successful delivery resets consecutive-failures to 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 by filter[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