Skip to content
Support

Webhooks

Webhooks allow you to receive real-time notifications when events occur in your LabelGrid account. Use webhooks to automate workflows and integrate with external systems.

For Developers: You can also manage webhooks programmatically via the API. See the LabelGrid API Documentation for endpoints and examples.

  1. You configure a webhook - Specify a URL and which events to listen for
  2. An event occurs - For example, a release is delivered to a store
  3. LabelGrid sends a POST request - Your server receives the event data
  4. Your system processes it - Automate workflows based on the event

  1. Click your profile icon in the top-right corner
  2. Select Webhooks from the dropdown menu

  1. Click Create Webhook
  2. Enter a Name to identify this webhook
  3. Enter the URL where you want to receive notifications
  4. Select which Events should trigger this webhook
  5. Click Create

When you create a webhook, you’ll receive a secret key. Use this to verify that incoming requests are actually from LabelGrid:

  • Store the secret securely
  • Verify the signature on incoming requests
  • If compromised, regenerate the secret

Configure your webhook to listen for these events. The Event Identifier is the value you’ll see in the event property of the payload and in the X-Webhook-Event header:

Event IdentifierDescription
delivery.completedTriggered when a release is successfully delivered to an outlet
delivery.failedTriggered when delivery to an outlet fails
takedown.completedTriggered when a takedown request completes
release.review.status_changedTriggered when a release review status changes
release.preflight.report_readyTriggered when the Preflight QC report for a held release is ready to fetch
stream_radar.flag_createdTriggered when a Stream Radar flag is raised, or a resolved flag reopens on a new detection
stream_radar.flag_resolvedTriggered when a Stream Radar flag resolves because detections stopped
release.distributedTriggered when a release is distributed
payment.statement_readyTriggered when a payment statement is ready for viewing
transcode.completedTriggered when a track’s audio transcode finishes successfully
transcode.failedTriggered when a track’s transcode fails or ends incomplete
distribution.outlet.status_changedTriggered on every per-outlet distribution status transition

You can select multiple events for a single webhook, or create separate webhooks for different event types.

You can also read this list programmatically: GET /api/public/webhooks/event-types returns every event together with a data schema describing its payload keys and types, so strict-schema consumers can widen their inbound validation ahead of time.


The webhook list shows:

ColumnDescription
NameThe webhook name you assigned
URLWhere notifications are sent
EventsNumber of events configured
StatusActive or Inactive
Success / FailCount of successful and failed deliveries
Last TriggeredWhen the webhook was last called
  1. Click the Edit action on the webhook row
  2. Modify the name, URL, or events
  3. Click Save

Toggle a webhook’s active status without deleting it:

  • Active - Webhook will receive notifications
  • Inactive - Webhook is paused, no notifications sent
  1. Click the Delete action on the webhook row
  2. Confirm the deletion

Before relying on a webhook in production, test it:

  1. Click the Test action on your webhook
  2. LabelGrid sends a test payload to your URL
  3. Check that your endpoint received and processed it correctly

Monitor webhook activity and troubleshoot issues:

  1. Click the View Logs action on a webhook
  2. See a history of all webhook deliveries

Each log entry shows:

FieldDescription
Event TypeWhich event triggered this delivery
Response StatusHTTP status code from your server
DurationHow long the request took
AttemptRetry attempt number
TimestampWhen the delivery occurred

When an event occurs, LabelGrid sends a POST request to your URL with a JSON payload:

{
"event": "delivery.completed",
"timestamp": "2026-05-05T10:00:00+00:00",
"webhook_id": "123",
"data": {
// Event-specific data
}
}

The timestamp field uses ISO 8601 format. webhook_id is the ID of your configured webhook (it matches the X-Webhook-Id header).


The data object structure depends on the event type. All field types below are JSON types as serialized in the payload.

Fired once per outlet when a release delivery reaches a terminal success state.

{
"event": "delivery.completed",
"timestamp": "2026-05-18T10:00:00+00:00",
"webhook_id": "123",
"data": {
"distro_queue_id": 456,
"release_id": 789,
"label_id": 321,
"release_cat": "ABC123",
"outlet_id": 12,
"outlet_name": "Spotify",
"status": "complete"
}
}
FieldTypeDescription
distro_queue_idintegerInternal queue ID for this delivery attempt
release_idintegerThe release that was delivered
label_idintegerThe release’s owning label, so you can route the event without a follow-up lookup
release_catstring | nullYour release catalog reference
outlet_idinteger | nullThe destination outlet ID
outlet_namestring | nullHuman-readable outlet name (e.g. "Spotify")
statusstringAlways "complete" for this event

Fired once per outlet when a release delivery reaches a terminal failure state. Same payload as delivery.completed plus a message field.

{
"event": "delivery.failed",
"timestamp": "2026-05-18T10:00:00+00:00",
"webhook_id": "123",
"data": {
"distro_queue_id": 456,
"release_id": 789,
"label_id": 321,
"release_cat": "ABC123",
"outlet_id": 12,
"outlet_name": "Spotify",
"status": "error",
"message": "Outlet rejected the delivery: missing ISRC."
}
}
FieldTypeDescription
statusstringOne of error, fault, rejected, batch_exception
messagestring | nullFailure reason from the outlet or distribution pipeline

Fired once per outlet when a takedown request succeeds. Same shape as delivery.completed plus a takedown: true flag.

{
"event": "takedown.completed",
"timestamp": "2026-05-18T10:00:00+00:00",
"webhook_id": "123",
"data": {
"distro_queue_id": 456,
"release_id": 789,
"label_id": 321,
"release_cat": "ABC123",
"outlet_id": 12,
"outlet_name": "Spotify",
"status": "complete",
"takedown": true
}
}

Fired once per release when the release transitions to the distributed delivery state. Only fires on the transition into distributed — not on subsequent saves while the release is already distributed.

{
"event": "release.distributed",
"timestamp": "2026-05-18T10:00:00+00:00",
"webhook_id": "123",
"data": {
"release_id": 789,
"label_id": 321,
"release_cat": "ABC123",
"release_title": "Summer EP",
"delivery_status": "distributed"
}
}

Fired whenever a release moves between review states.

{
"event": "release.review.status_changed",
"timestamp": "2026-05-18T10:00:00+00:00",
"webhook_id": "123",
"data": {
"release_id": 789,
"label_id": 321,
"release_cat": "ABC123",
"release_title": "Summer EP",
"previous_status": "to_review",
"new_status": "approved"
}
}
FieldTypeDescription
previous_statusstringPrior status. One of draft, to_review, approved, rejected, require_changes, audit
new_statusstringNew status. Same set of values
review_issuesarray (optional)Present only on require_changes and rejected transitions: the issues that need your attention. The key is omitted on every other transition, so don’t treat it as always present

Fired when the Preflight QC quality report for a release on the pre-review hold is ready to fetch. Requires the Preflight QC add-on on your account.

{
"event": "release.preflight.report_ready",
"timestamp": "2026-07-07T10:00:00+00:00",
"webhook_id": "123",
"data": {
"release_id": 789,
"label_id": 321,
"release_cat": "ABC123",
"release_title": "Summer EP",
"generated_at": "2026-07-07T09:58:12+00:00",
"profile": { "name": "quality_report", "version": 2 },
"counts": { "blocking": 1, "informational": 2, "requires_feedback": 1 }
}
}
FieldTypeDescription
release_idintegerThe release the report belongs to
label_idintegerThe release’s owning label
release_catstring | nullYour release catalog reference
release_titlestring | nullThe release title
generated_atstringWhen the checks completed (ISO 8601). Matches the quality-report endpoint’s report.generated_at
profileobjectThe quality profile the counts were computed through: {name, version}
countsobjectAggregate counts only: {blocking, informational, requires_feedback}. The requires_feedback count overlaps the other two

Fired when a Stream Radar flag is raised — either a brand-new flag or a previously resolved flag reopening on a new detection. Requires the Stream Radar add-on on your account. The transition field tells the two cases apart: published for a new flag, reopened for one that became active again.

{
"event": "stream_radar.flag_created",
"timestamp": "2026-07-07T10:00:00+00:00",
"webhook_id": "123",
"data": {
"flag_id": 4501,
"dsp": "spotify",
"isrc": "USRC12345678",
"release_id": 789,
"track_id": 654,
"severity": "high",
"status": "active",
"transition": "published",
"first_detected_at": "2026-07-06T00:00:00+00:00",
"last_detected_at": "2026-07-07T00:00:00+00:00",
"estimated_affected_streams": 12500,
"published_at": "2026-07-07T09:58:12+00:00",
"resolved_at": null
}
}
FieldTypeDescription
flag_idintegerThe flag’s stable identifier; matches id on the Stream Radar endpoints
dspstringThe platform the pattern was seen on (e.g. spotify)
isrcstringThe ISRC of the recording involved
release_idintegerThe release the recording belongs to
track_idinteger | nullThe specific track, when the ISRC maps unambiguously to one of your tracks
severitystringlow, medium, or high
statusstringactive for this event
transitionstringpublished for a new flag, reopened when a resolved flag became active again
first_detected_atstring | nullWhen the pattern was first seen for this track and platform (ISO 8601)
last_detected_atstring | nullThe most recent detection (ISO 8601)
estimated_affected_streamsinteger | nullAn estimate of how many streams are involved
published_atstringWhen the flag was first raised to you (ISO 8601)
resolved_atstring | nullnull while the flag is active

Fired when a Stream Radar flag resolves because detections stopped. Requires the Stream Radar add-on. Same fields as stream_radar.flag_created (without transition), with status set to resolved and resolved_at populated.

{
"event": "stream_radar.flag_resolved",
"timestamp": "2026-07-14T10:00:00+00:00",
"webhook_id": "123",
"data": {
"flag_id": 4501,
"dsp": "spotify",
"isrc": "USRC12345678",
"release_id": 789,
"track_id": 654,
"severity": "high",
"status": "resolved",
"first_detected_at": "2026-07-06T00:00:00+00:00",
"last_detected_at": "2026-07-12T00:00:00+00:00",
"estimated_affected_streams": 18700,
"published_at": "2026-07-07T09:58:12+00:00",
"resolved_at": "2026-07-14T09:55:03+00:00"
}
}

Fired when a payment statement is generated and ready for viewing.

{
"event": "payment.statement_ready",
"timestamp": "2026-05-18T10:00:00+00:00",
"webhook_id": "123",
"data": {
"payment_request_id": 1024,
"invoice_number": "INV-2026-001",
"period": "2026-04-30",
"amount": 1234.56,
"total_due_usd": 1234.56,
"currency": "USD"
}
}
FieldTypeDescription
payment_request_idintegerInternal payment request ID
invoice_numberstringInvoice reference for the statement
periodstring | nullEnd-of-period date (ISO 8601 date, YYYY-MM-DD)
amountnumberStatement amount in currency
total_due_usdnumberStatement total converted to USD
currencystringISO 4217 currency code (defaults to USD)

Fired when a track’s audio transcode finishes. transcode.completed fires on success; transcode.failed fires when the transcode fails or ends incomplete. Both share the same payload shape.

{
"event": "transcode.completed",
"timestamp": "2026-07-07T10:00:00+00:00",
"webhook_id": "123",
"data": {
"release_id": 789,
"label_id": 321,
"track_id": 654,
"transcoder_queue_id": 987,
"status": "complete",
"status_message": "transcode_complete",
"files": [
{ "asset_type_id": 2, "status": "complete" }
]
}
}
FieldTypeDescription
release_idintegerThe release the track belongs to
label_idintegerThe release’s owning label
track_idintegerThe track that was transcoded
transcoder_queue_idintegerInternal transcode queue ID
statusstringRaw queue status: complete, error, or incomplete
status_messagestringSafe, enumerated reason code: transcode_complete, transcode_error, or transcode_incomplete
filesarrayPer-file detail for the track: {asset_type_id, status} per transcoded file

Fired on every per-outlet distribution status transition (for example scheduled → transcoding → batched → complete), not only the terminal ones covered by delivery.completed, delivery.failed, and takedown.completed. This event is chatty by design: subscribe to it only if you want the full per-outlet progression.

{
"event": "distribution.outlet.status_changed",
"timestamp": "2026-07-07T10:00:00+00:00",
"webhook_id": "123",
"data": {
"distro_queue_id": 456,
"release_id": 789,
"label_id": 321,
"release_cat": "ABC123",
"outlet_id": 12,
"outlet_name": "Spotify",
"previous_status": "transcoding",
"status": "batched"
}
}
FieldTypeDescription
distro_queue_idintegerInternal queue ID for this delivery
release_idintegerThe release being distributed
label_idintegerThe release’s owning label
release_catstring | nullYour release catalog reference
outlet_idinteger | nullThe destination outlet ID
outlet_namestring | nullHuman-readable outlet name
previous_statusstring | nullThe prior status; null when the row had no recognised previous status
statusstringThe new status

Every webhook delivery is signed so you can verify it actually came from LabelGrid. Always verify the signature before processing the event.

Every webhook POST request includes these headers:

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 of the raw request body, lowercase hex, no algorithm prefix
X-Webhook-TimestampConvenience copy of the timestamp property in the body. Not covered by the signature — never use it to decide whether a delivery is fresh.
X-Webhook-EventEvent identifier (e.g., delivery.completed)
X-Webhook-IdThe ID of the webhook configuration receiving the delivery (not a per-delivery ID)
User-AgentLabelGrid-Webhooks/1.0
Content-Typeapplication/json
  • Algorithm: HMAC-SHA256
  • Encoding: Lowercase hexadecimal
  • Prefix: None — the value is just the hex digest, not sha256=...
  • Signed content: The full raw JSON request body — and nothing else. No header is signed.

The body carries its own timestamp property, so that value is protected by the signature. The X-Webhook-Timestamp header is only a duplicate of it, sent for convenience, and an attacker who captures a delivery can change the header freely without invalidating the signature. Freshness checks must therefore read timestamp from the parsed body, never from the header.

  1. Read the raw request body before any JSON parsing or transformation. Re-serializing the parsed JSON may produce different bytes and break the signature.
  2. Compute HMAC-SHA256(raw_body, your_webhook_secret) and take the lowercase hex digest.
  3. Compare against X-Webhook-Signature using a constant-time comparison. Stop here if it does not match.
  4. Only now parse the body, and reject the request if its timestamp property is older than your replay tolerance window — we suggest 5 minutes. Because that value is signed, an attacker cannot refresh it to make a captured delivery look current.

Each retry is signed again with a new timestamp, so a 5-minute window never rejects a legitimate retry, however far into the backoff schedule it arrives.

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
// Parse only after the bytes are proven authentic.
$payload = json_decode($rawBody, true);
// Freshness comes from the SIGNED timestamp in the body,
// never from the X-Webhook-Timestamp header.
if (! isset($payload['timestamp'])
|| abs(time() - strtotime($payload['timestamp'])) > 300) {
http_response_code(401);
exit('Stale delivery');
}
// ... process the event (see Handling Repeat Deliveries below)
http_response_code(200);
const crypto = require('crypto');
// Express: capture raw body BEFORE any JSON middleware
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body; // Buffer
const signature = req.header('X-Webhook-Signature') || '';
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
const sigBuf = Buffer.from(signature, 'hex');
const expBuf = Buffer.from(expected, 'hex');
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('Invalid signature');
}
// Parse only after the bytes are proven authentic.
const payload = JSON.parse(rawBody.toString('utf8'));
// Freshness comes from the SIGNED timestamp in the body,
// never from the X-Webhook-Timestamp header.
const sentAt = new Date(payload.timestamp).getTime();
if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > 5 * 60 * 1000) {
return res.status(401).send('Stale delivery');
}
// ... process the event (see Handling Repeat Deliveries below)
res.sendStatus(200);
});
import hmac, hashlib, json
from datetime import datetime, timezone
raw_body = request.get_data() # Flask: bytes, before any JSON parsing
signature = request.headers.get('X-Webhook-Signature', '')
expected = hmac.new(
webhook_secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
return ('Invalid signature', 401)
payload = json.loads(raw_body) # parse only once the bytes are authentic
delivery_time = datetime.fromisoformat(payload['timestamp']) # the SIGNED value, never the header
if abs((datetime.now(timezone.utc) - delivery_time).total_seconds()) > 300:
return ('Stale delivery', 401)
return ('', 200) # process the event, then ack
  • Checking freshness against the X-Webhook-Timestamp header. The header is not signed. Anyone who captures a delivery can replay the identical body and signature with a fresh header value and pass a header-based check forever. Read timestamp from the parsed body instead — that value is signed.
  • Re-serializing the body before hashing. Frameworks that auto-parse JSON (Express express.json(), Laravel default request body) lose the original bytes. Capture the raw body first.
  • Using a non-constant-time comparison (==, ===). Susceptible to timing attacks — always use hash_equals (PHP), crypto.timingSafeEqual (Node), hmac.compare_digest (Python), or your language’s equivalent.
  • Expecting a sha256= prefix. The header value is just the hex digest with no prefix.
  • Skipping the freshness check. Without it, a captured delivery can be replayed against your endpoint indefinitely.
  • Trusting X-Webhook-Id as a delivery ID. It identifies the webhook configuration, not the individual delivery, and it is not signed either.

Webhook delivery is at-least-once: a delivery your endpoint actually processed can still arrive again if your 2xx response was lost or arrived after the 10-second timeout, and LabelGrid then retries it. Signature verification proves a request is authentic — it does not prove it is one you have not already handled.

Deliveries do not carry a unique per-delivery ID, so build your own idempotency key from the signed payload. The event type plus the identifiers in data are usually enough — for example delivery.completed plus distro_queue_id, or transcode.completed plus track_id. Record the key when you process an event and ignore anything you have already recorded.

Do not use the signature or the timestamp as that key. Every attempt is signed fresh at the moment it is sent, so a retry of an event you already handled arrives with a different timestamp and a different signature — the key has to come from the event’s own identifiers.

Combine that with the freshness check above: freshness bounds how long a captured delivery stays replayable, and idempotency makes a repeat harmless whether it came from a retry or an attacker inside the window.


LimitValue
Request timeout10 seconds
Maximum payload size64 KB
Maximum webhooks per user10

If your endpoint does not respond within 10 seconds, the delivery is treated as a failure and retried.

If your endpoint returns a non-2xx status or times out, LabelGrid retries with exponential backoff:

AttemptWait before retry
1 → 230 seconds
2 → 31 minute
3 → 42 minutes
4 → 54 minutes
5 → 68 minutes
6 → 716 minutes
7 → 832 minutes
8 → 964 minutes
9 → 10128 minutes

Each interval includes 0–30 seconds of jitter. After 10 attempts (~4.5 hours total elapsed), the delivery is logged as permanently failed and does not retry further.

If a webhook endpoint keeps failing — repeated delivery failures with no successful delivery in between — LabelGrid automatically disables the webhook so it stops retrying an endpoint that clearly can’t receive events. The failure count resets on every successful delivery, so an occasional hiccup never disables a webhook; only sustained, uninterrupted failure does.

When a webhook is disabled this way, its owner receives an email. The email names the webhook and its endpoint URL, and the kind of failure that triggered the disablement — for example, a connection timeout or repeated HTTP errors.

Re-enabling is self-service: fix your endpoint, then toggle the webhook back on under Profile → Webhooks. Reactivating a disabled webhook resets its failure count. The webhook list shows each webhook’s active status and current failure count, so you can spot an unhealthy endpoint at a glance.

  • Return a 2xx response quickly (within 10 seconds)
  • Process the data asynchronously after acknowledging
  • Verify the signature on every request (see Verifying Webhook Signatures)
  • Make your handler idempotent — delivery is at-least-once (see Handling Repeat Deliveries)
  • Monitor your failure count in the webhook list
  • Check delivery logs when investigating missed events

  • Send Slack messages when releases go live
  • Email your team when deliveries fail
  • Update internal dashboards
  • Trigger marketing campaigns when releases are distributed
  • Update your website when new content is available
  • Sync status to external project management tools
  • Get instant alerts for delivery failures
  • Track distribution progress in real-time
  • Monitor review status changes

  1. Check status - Is the webhook Active?
  2. Verify URL - Is the endpoint accessible from the internet?
  3. Check events - Are the right events selected?
  4. Review logs - Any errors recorded?
  1. Check your endpoint - Is it returning 200 OK?
  2. Check response time - Is it responding within timeout?
  3. Review error messages - What’s failing?
  4. Test manually - Send a test webhook

If your webhook secret is compromised:

  1. Click Regenerate Secret in webhook settings
  2. Update your application with the new secret
  3. Old secret immediately stops working

If you have questions about webhooks, contact our support team.

Not using LabelGrid yet?

Everything you just read about is available on our platform.

See what LabelGrid can do →