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-TimestampISO 8601 timestamp of the delivery (same value as the timestamp property in the body)
X-Webhook-EventEvent identifier (e.g., delivery.completed)
X-Webhook-IdThe ID of the webhook receiving the delivery
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 (the body itself includes the timestamp as a property, so the timestamp is signed implicitly)
  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.
  4. (Recommended) Reject the request if X-Webhook-Timestamp is older than your replay tolerance window — we suggest 5 minutes.
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
// Reject deliveries older than 5 minutes
if (abs(time() - strtotime($timestamp)) > 300) {
http_response_code(401);
exit('Stale delivery');
}
$payload = json_decode($rawBody, true);
// ... process the event
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 timestamp = req.header('X-Webhook-Timestamp') || '';
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');
}
if (Math.abs(Date.now() - new Date(timestamp).getTime()) > 5 * 60 * 1000) {
return res.status(401).send('Stale delivery');
}
const payload = JSON.parse(rawBody.toString('utf8'));
// ... process the event
res.sendStatus(200);
});
import hmac, hashlib
from datetime import datetime, timezone
raw_body = request.get_data() # Flask: bytes, before any JSON parsing
signature = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
expected = hmac.new(
webhook_secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
return ('Invalid signature', 401)
delivery_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
if abs((datetime.now(timezone.utc) - delivery_time).total_seconds()) > 300:
return ('Stale delivery', 401)
return ('', 200) # process the event, then ack
  • 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 timestamp check. Without it, a leaked signature could be replayed indefinitely.

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 accumulates 100 cumulative failed deliveries, it is automatically disabled. Re-enable it manually in the webhook list after fixing your endpoint. Reactivating a disabled webhook resets its failure count.

  • Return a 2xx response quickly (within 10 seconds)
  • Process the data asynchronously after acknowledging
  • Verify the signature on every request (see Verifying Webhook Signatures)
  • 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 →