Skip to main content

Overview

Webhooks enable real-time event notifications between Royalti.io and your systems. This guide covers two distinct webhook systems:
  1. Outbound Webhooks - Royalti sends event notifications TO your endpoints when events occur
  2. Inbound Webhooks - Your systems send status updates TO Royalti endpoints

Why Use Webhooks?

  • Real-time Updates: Get notified instantly when events occur instead of polling APIs
  • Automation: Trigger workflows in your systems based on Royalti events
  • Efficiency: Reduce API calls and server load
  • Integration: Connect Royalti with external tools (CRMs, analytics, notifications)

Outbound Webhooks (Royalti Sends to You)

Outbound webhooks allow you to receive real-time notifications when events occur in your Royalti workspace.

Quick Start

1

Configure Webhook URL

Set your webhook endpoint URL via Tenant Settings API:
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-url \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value": "https://your-domain.com/webhooks/royalti"}'
2

Enable Webhooks

Activate webhook delivery:
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-isActive \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value": "true"}'
3

Subscribe to Events

Choose which events to receive (comma-separated):
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-enabledEvents \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value": "PAYMENT_COMPLETED,ASSET_CREATED,ROYALTY_FILE_PROCESSED"}'
4

Implement Endpoint

Create an HTTP endpoint that accepts POST requests:
app.post('/webhooks/royalti', (req, res) => {
  const event = req.body;
  console.log(`Received event: ${event.event}`);
  console.log(`Event data:`, event.data);
  // Process the event
  // ... your business logic here
  // Always respond with 200 to acknowledge receipt
  res.status(200).json({ received: true });
});

Event Catalog

Royalti.io supports 20+ event types across multiple categories:

Financial Events (Require Signature Validation)

These events involve sensitive financial data and automatically enforce HMAC signature validation:
Event TypeDescriptionPayload Highlights
PAYMENT_COMPLETEDPayment successfully processedamount, currency, recipientId
PAYMENT_PROCESSINGPayment in progressamount, status, estimatedCompletion
PAYMENT_MADE_FAILEDPayment failedamount, errorCode, errorMessage
PAYMENT_REQUEST_SENTPayment request createdrequestId, amount, recipientEmail
PAYMENT_REQUEST_APPROVEDPayment request approvedrequestId, approvedBy, approvedAt
PAYMENT_REQUEST_REJECTEDPayment request rejectedrequestId, rejectedBy, reason
PAYMENT_DELETEDPayment record deletedpaymentId, deletedBy
REVENUE_CREATEDRevenue entry createdamount, source, period
REVENUE_UPDATEDRevenue entry updatedrevenueId, changes, previousAmount
REVENUE_DELETEDRevenue entry deletedrevenueId, amount, reason
EXPENSE_CREATEDExpense entry createdamount, category, description
EXPENSE_UPDATEDExpense entry updatedexpenseId, changes
EXPENSE_DELETEDExpense entry deletedexpenseId, amount

Catalog Events (Default Enabled)

Event TypeDescriptionPayload Highlights
ASSET_CREATEDNew asset addedassetId, title, artists, mediaType
ASSET_UPDATEDAsset modifiedassetId, changes, previous
ASSET_DELETEDAsset removedassetId, title, deletedBy
PRODUCT_CREATEDNew product/album createdproductId, title, assets, releaseDate
PRODUCT_UPDATEDProduct modifiedproductId, changes, previous
PRODUCT_DELETEDProduct removedproductId, title

Roster Events (Default Enabled)

Event TypeDescriptionPayload Highlights
USER_CREATEDNew user added to workspaceuserId, email, role, name
USER_UPDATEDUser profile/permissions modifieduserId, changes, updatedBy
USER_DELETEDUser removed from workspaceuserId, email, deletedBy
USER_INVITATION_SENTUser invited to workspaceinviteId, email, role, invitedBy
ARTIST_CREATEDNew artist profile createdartistId, name, genres
ARTIST_UPDATEDArtist profile modifiedartistId, changes
ARTIST_DELETEDArtist profile removedartistId, name

Royalty Events (Require Explicit Opt-in)

High-volume events that require explicit opt-in to prevent overwhelming webhooks:
Event TypeDescriptionPayload Highlights
ROYALTY_FILE_UPLOADEDRoyalty file uploadedfileId, fileName, source, uploadedBy
ROYALTY_FILE_PROCESSEDRoyalty file processing completedfileId, recordsProcessed, totalAmount, duration
ROYALTY_FILE_PROCESSING_FAILEDRoyalty file processing failedfileId, errorCode, errorMessage, failedAt

Split Events (Require Signature Validation)

Event TypeDescriptionPayload Highlights
USER_ADDED_TO_SPLITUser added to revenue splitsplitId, userId, percentage, resource
USER_REMOVED_FROM_SPLITUser removed from splitsplitId, userId, removedBy, reason

Webhook Payload Structure

All outbound webhooks follow this standardized structure:
{
  "id": "whd_f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "event": "PAYMENT_COMPLETED",
  "timestamp": "2025-01-22T14:30:00.000Z",
  "version": "1.0",
  "tenant": {
    "id": 123,
    "name": "Artist Label Inc.",
    "domain": "artist-label.royalti.io"
  },
  "source": {
    "service": "royalti-api",
    "environment": "production",
    "traceId": "req_abc123xyz"
  },
  "data": {
    "event": {
      "id": "evt_456def",
      "type": "PAYMENT_COMPLETED",
      "category": "FINANCIAL",
      "timestamp": "2025-01-22T14:30:00.000Z",
      "importance": "critical"
    },
    "actor": {
      "id": "usr_789ghi",
      "type": "user",
      "name": "John Admin",
      "email": "john@artist-label.com"
    },
    "resource": {
      "type": "payment",
      "id": "pay_123abc",
      "url": "/api/v1/payment/pay_123abc",
      "displayName": "Payment to Artist Name"
    },
    "attributes": {
      "amount": 1500.00,
      "currency": "USD",
      "recipientId": "usr_456def",
      "recipientName": "Artist Name",
      "method": "stripe",
      "transactionId": "txn_stripe_789xyz",
      "status": "completed"
    },
    "previous": {
      "status": "processing",
      "updatedAt": "2025-01-22T14:25:00.000Z"
    }
  },
  "delivery": {
    "attempt": 1,
    "maxAttempts": 3,
    "nextRetryAt": null
  }
}

Field Descriptions

FieldTypeDescription
idstringUnique webhook delivery ID (format: whd_{uuid})
eventstringEvent type from NotificationType enum
timestampISO 8601When the event occurred
versionstringWebhook payload schema version (currently “1.0”)
tenant.idintegerYour tenant/workspace ID
tenant.namestringYour workspace name
tenant.domainstringYour workspace domain
source.servicestringService that generated the event
source.environmentstringEnvironment (production, staging, development)
source.traceIdstringRequest correlation ID for debugging
data.eventobjectEvent metadata (ID, type, category, importance)
data.actorobjectWho/what triggered the event
data.resourceobjectThe resource affected by the event
data.attributesobjectEvent-specific data (varies by event type)
data.previousobjectPrevious state (for update events only)
delivery.attemptintegerCurrent delivery attempt (1-based)
delivery.maxAttemptsintegerMaximum retry attempts configured
delivery.nextRetryAtISO 8601When next retry will occur (null if no retry)

Webhook Configuration

Configure your webhook behavior through Tenant Settings:

Available Settings

Setting NameTypeDefaultDescription
webhook-urlstring-Your webhook endpoint URL (must be HTTPS)
webhook-isActivebooleanfalseEnable/disable webhook delivery
webhook-enabledEventsstring-Comma-separated list of event types or “all”
webhook-enableSignatureValidationbooleanfalseEnable HMAC-SHA256 signature validation
webhook-secretTokenstring-Secret token for signature generation (auto-generated if empty)
webhook-retryAttemptsnumber3Number of retry attempts (1-10)
webhook-timeoutMsnumber30000Request timeout in milliseconds (1000-120000)
webhook-requireHttpsbooleantrueRequire HTTPS URLs (security best practice)
webhook-customHeadersstring-JSON object of custom headers to send
webhook-userAgentstring”Royalti-Webhooks/1.0”Custom User-Agent header

Example: Complete Webhook Configuration

# Set webhook URL
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-url \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "https://your-domain.com/webhooks/royalti"}'
# Enable webhooks
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-isActive \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "true"}'
# Subscribe to specific events
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-enabledEvents \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "PAYMENT_COMPLETED,ASSET_CREATED,USER_CREATED,ROYALTY_FILE_PROCESSED"}'
# Enable signature validation (recommended for production)
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-enableSignatureValidation \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "true"}'
# Set secret token (use a strong, random value)
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-secretToken \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "your-secure-random-secret-token-min-32-chars"}'
# Configure retry attempts
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-retryAttempts \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "5"}'
# Set timeout (45 seconds)
curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-timeoutMs \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "45000"}'

Webhook Monitoring & Analytics

Track your webhook performance using the Webhook Deliveries API:

Get Delivery History

curl -X GET "https://server26-dot-royalti-project.uc.r.appspot.com/webhook-deliveries?page=1&size=20&status=failed" \
  -H "Authorization: Bearer YOUR_TOKEN"
Response:
{
  "status": "success",
  "message": "Webhook deliveries retrieved successfully",
  "data": {
    "webhookDeliveries": [
      {
        "id": "whd_123",
        "eventType": "PAYMENT_COMPLETED",
        "requestUrl": "https://your-domain.com/webhooks/royalti",
        "deliveryStatus": "failed",
        "responseStatus": 500,
        "responseTimeMs": 5234,
        "attemptNumber": 3,
        "finalAttempt": true,
        "errorCode": "SERVER_ERROR",
        "errorMessage": "Internal server error",
        "createdAt": "2025-01-22T14:30:00Z"
      }
    ],
    "totalItems": 150,
    "totalPages": 8,
    "currentPage": 1
  }
}

Get Delivery Summary

curl -X GET "https://server26-dot-royalti-project.uc.r.appspot.com/webhook-deliveries/summary" \
  -H "Authorization: Bearer YOUR_TOKEN"
Response:
{
  "status": "success",
  "data": {
    "summary": {
      "totalDeliveries": 1542,
      "successRate": "95.8%",
      "dateRange": {
        "start": "2025-01-01T00:00:00Z",
        "end": "2025-01-22T23:59:59Z"
      }
    },
    "statusBreakdown": {
      "success": 1477,
      "failed": 45,
      "timeout": 15,
      "pending": 5
    },
    "topEventTypes": [
      {"eventType": "PAYMENT_COMPLETED", "count": 523},
      {"eventType": "ASSET_CREATED", "count": 412},
      {"eventType": "USER_CREATED", "count": 287}
    ],
    "recentFailures": [
      {
        "id": "whd_123",
        "eventType": "PAYMENT_COMPLETED",
        "errorCode": "TIMEOUT",
        "createdAt": "2025-01-22T14:30:00Z"
      }
    ]
  }
}

Get Detailed Analytics

curl -X GET "https://server26-dot-royalti-project.uc.r.appspot.com/webhook-deliveries/stats?timeframe=7d" \
  -H "Authorization: Bearer YOUR_TOKEN"

Retry Failed Delivery

curl -X POST "https://server26-dot-royalti-project.uc.r.appspot.com/webhook-deliveries/whd_123/retry" \
  -H "Authorization: Bearer YOUR_TOKEN"

Security Best Practices

HMAC Signature Verification

When signature validation is enabled, Royalti signs all webhook payloads using HMAC-SHA256.

Headers Sent by Royalti

Content-Type: application/json
X-Royalti-Delivery: whd_f47ac10b-58cc-4372-a567-0e02b2c3d479
X-Royalti-Event: PAYMENT_COMPLETED
X-Royalti-Timestamp: 1706019000
X-Royalti-Tenant: 123
X-Royalti-Attempt: 1
X-Royalti-Version: 1.0
X-Royalti-Signature: sha256=5d41402abc4b2a76b9719d911017c592

Verification Implementation

Security Warning: Always use timing-safe comparison functions (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hash_equals in PHP) to prevent timing attacks.

URL Validation

Royalti enforces strict URL validation for webhook endpoints: ✅ Allowed:
  • HTTPS URLs (required by default)
  • Ports: 80, 443, 8080, 8443
  • Public IP addresses and domains
⚠️ Blocked:
  • HTTP URLs (unless webhook-requireHttps is false)
  • Private network ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
  • Localhost (127.0.0.0/8)
  • URLs longer than 2048 characters

Rate Limiting

Webhooks are subject to per-tenant rate limits:
  • Default: 100 deliveries per minute per webhook
  • Auto-disable: Webhooks are automatically disabled after 50 consecutive failures
  • Re-enable: Contact support or manually re-enable via settings

Error Handling & Retry Logic

HTTP Status Codes

Your webhook endpoint should return appropriate HTTP status codes:
Status CodeMeaningRoyalti’s Action
200-299SuccessMarks delivery as successful, no retry
408, 504TimeoutMarks as timeout, retries with exponential backoff
400-499 (except 408)Client errorMarks as failed, logs error, no retry
500-599 (except 504)Server errorMarks as failed, retries with exponential backoff

Retry Strategy

Royalti automatically retries failed webhook deliveries:
  1. Initial Attempt: Delivered immediately when event occurs
  2. Retry 1: After 2 seconds (if initial failed)
  3. Retry 2: After 4 seconds (exponential backoff)
  4. Retry 3: After 8 seconds (exponential backoff)
  5. Final: Marked as failed after max attempts exhausted
Configuration:
  • webhook-retryAttempts: Set max retry attempts (default: 3, max: 10)
  • Exponential backoff with 2-second initial delay
  • Each retry doubles the delay

Error Categories

Royalti categorizes webhook errors for easier debugging:
Error CodeDescriptionCommon Causes
TIMEOUTRequest timed outEndpoint too slow, network issues
NETWORK_ERRORConnection failedDNS failure, connection refused, socket errors
CLIENT_ERROR4xx status codeInvalid request, authentication failure
SERVER_ERROR5xx status codeInternal server error on your endpoint
SSL_ERRORSSL/TLS failureInvalid certificate, expired certificate
INVALID_URLURL validation failedPrivate IP, missing HTTPS, invalid format
UNKNOWN_ERRORUnclassified errorUnexpected errors

Manual Retry

You can manually retry failed deliveries via API:
curl -X POST "https://server26-dot-royalti-project.uc.r.appspot.com/webhook-deliveries/whd_123/retry" \
  -H "Authorization: Bearer YOUR_TOKEN"
Manual retries create a new delivery attempt with a 5-second delay and 1 retry attempt. The original delivery is marked as finalAttempt: false.

Testing & Development

Local Testing with ngrok

To test webhooks locally, use ngrok to expose your local server:
1

Start your local server

node server.js
# Server running on http://localhost:3000
2

Start ngrok tunnel

ngrok http 3000
Copy the HTTPS URL provided (e.g., https://abc123.ngrok.io)
3

Configure webhook URL

curl -X PUT https://server26-dot-royalti-project.uc.r.appspot.com/tenant/settings/webhook-url \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "https://abc123.ngrok.io/webhooks/royalti"}'
4

Trigger test events

Create an asset, make a payment, or perform other actions in Royalti to trigger webhook events.

Test Payload Examples

Payment Completed Event

{
  "id": "whd_test_123",
  "event": "PAYMENT_COMPLETED",
  "timestamp": "2025-01-22T14:30:00.000Z",
  "version": "1.0",
  "tenant": {"id": 123, "name": "Test Workspace"},
  "source": {"service": "royalti-api", "environment": "production"},
  "data": {
    "event": {
      "id": "evt_456",
      "type": "PAYMENT_COMPLETED",
      "category": "FINANCIAL",
      "importance": "critical"
    },
    "actor": {
      "id": "usr_789",
      "type": "user",
      "name": "Test User"
    },
    "resource": {
      "type": "payment",
      "id": "pay_abc",
      "displayName": "Payment to Test Artist"
    },
    "attributes": {
      "amount": 1000.00,
      "currency": "USD",
      "recipientId": "usr_def",
      "method": "stripe",
      "status": "completed"
    }
  },
  "delivery": {"attempt": 1, "maxAttempts": 3}
}

Asset Created Event

{
  "id": "whd_test_456",
  "event": "ASSET_CREATED",
  "timestamp": "2025-01-22T15:00:00.000Z",
  "version": "1.0",
  "tenant": {"id": 123, "name": "Test Workspace"},
  "source": {"service": "royalti-api", "environment": "production"},
  "data": {
    "event": {
      "id": "evt_789",
      "type": "ASSET_CREATED",
      "category": "CATALOG",
      "importance": "medium"
    },
    "actor": {
      "id": "usr_123",
      "type": "user",
      "name": "Test User"
    },
    "resource": {
      "type": "asset",
      "id": "ast_xyz",
      "displayName": "New Song Title",
      "url": "/api/v1/asset/ast_xyz"
    },
    "attributes": {
      "title": "New Song Title",
      "artists": ["Artist Name"],
      "mediaType": "audio",
      "duration": 210,
      "isrc": "USRC12345678",
      "releaseDate": "2025-02-01"
    }
  },
  "delivery": {"attempt": 1, "maxAttempts": 3}
}

Inbound Webhooks (You Send to Royalti)

Inbound webhooks allow external systems to send status updates TO Royalti when events occur in your systems.

Royalty File Status Update

Notify Royalti when royalty file processing completes in your external system. Endpoint: POST /webhook/royalty/file-update/webhook Authentication: royalti-x-hash header with HMAC signature Payload:
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "tenant": 123
}
Example:
const crypto = require('crypto');
const axios = require('axios');
async function notifyRoyaltiFileProcessed(fileId, tenantId) {
  const payload = {
    id: fileId,
    tenant: tenantId
  };
  const payloadString = JSON.stringify(payload);
  const secret = process.env.ROYALTY_SECRET_HASH;
  // Generate HMAC signature
  const signature = crypto
    .createHmac('sha256', secret)
    .update(payloadString)
    .digest('hex');
  try {
    const response = await axios.post(
      'https://server26-dot-royalti-project.uc.r.appspot.com/webhook/royalty/file-update/webhook',
      payload,
      {
        headers: {
          'Content-Type': 'application/json',
          'royalti-x-hash': signature
        }
      }
    );
    console.log('Webhook sent successfully:', response.data);
  } catch (error) {
    console.error('Webhook failed:', error.message);
  }
}
// Usage
notifyRoyaltiFileProcessed('f47ac10b-58cc-4372-a567-0e02b2c3d479', 123);

Download Status Update

Notify Royalti when a download file is ready. Endpoint: POST /webhook/download/status-update/webhook Authentication: royalti-x-hash header with HMAC signature Payload:
{
  "id": "d3f2c1b0-1234-5678-90ab-cdef12345678",
  "status": "done",
  "url": "https://storage.googleapis.com/downloads/export-2024-01-15.csv?signature=..."
}
Example:
import hmac
import hashlib
import json
import requests
import os
def notify_download_ready(download_id, status, url):
    payload = {
        "id": download_id,
        "status": status,
        "url": url
    }
    payload_string = json.dumps(payload)
    secret = os.getenv('ROYALTY_SECRET_HASH')
    # Generate HMAC signature
    signature = hmac.new(
        secret.encode('utf-8'),
        payload_string.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    headers = {
        'Content-Type': 'application/json',
        'royalti-x-hash': signature
    }
    response = requests.post(
        'https://server26-dot-royalti-project.uc.r.appspot.com/webhook/download/status-update/webhook',
        json=payload,
        headers=headers
    )
    print(f"Webhook sent: {response.status_code}")
    return response.json()
# Usage
notify_download_ready(
    download_id='d3f2c1b0-1234-5678-90ab-cdef12345678',
    status='done',
    url='https://storage.googleapis.com/downloads/export.csv'
)

Third-Party Webhooks (System Integration)

Royalti integrates with third-party services that send webhooks to the platform.

Stripe Billing Events

Royalti receives Stripe webhooks for subscription and billing events. Supported Events:
  • Subscription lifecycle (created, updated, deleted)
  • Invoice events (payment succeeded, payment failed)
  • Checkout session completed
  • Customer created/updated
  • Billing meter events
These webhooks are handled automatically by Royalti. You don’t need to configure anything - Stripe sends them directly to Royalti’s webhook endpoint.

Cloudflare Domain & SSL Status

For custom domains configured through Cloudflare SaaS, Royalti receives:
  • Custom hostname events (created, deleted)
  • SSL validation events (completed, failed)
  • SSL renewal events (renewed, renewal failed)

Complete Code Examples

Express.js Webhook Server

const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: Store raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));
// Webhook signature verification
function verifySignature(payload, signature, secret) {
  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}
// Webhook handler
app.post('/webhooks/royalti', (req, res) => {
  const signature = req.headers['x-royalti-signature'];
  const secret = process.env.ROYALTI_WEBHOOK_SECRET;
  // Verify signature
  if (!verifySignature(req.rawBody, signature, secret)) {
    console.error('Invalid webhook signature');
    return res.status(401).json({ error: 'Invalid signature' });
  }
  const event = req.body;
  console.log(`\nReceived webhook: ${event.event}`);
  console.log(`Delivery ID: ${event.id}`);
  console.log(`Timestamp: ${event.timestamp}`);
  console.log(`Attempt: ${event.delivery.attempt}/${event.delivery.maxAttempts}`);
  // Route to event handlers
  try {
    switch (event.event) {
      case 'PAYMENT_COMPLETED':
        handlePaymentCompleted(event.data);
        break;
      case 'ASSET_CREATED':
        handleAssetCreated(event.data);
        break;
      case 'ROYALTY_FILE_PROCESSED':
        handleRoyaltyFileProcessed(event.data);
        break;
      default:
        console.log(`Unhandled event type: ${event.event}`);
    }
    // Always respond with 200 to acknowledge receipt
    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Error processing webhook:', error);
    // Return 500 to trigger retry
    res.status(500).json({ error: 'Processing failed' });
  }
});
// Event handlers
function handlePaymentCompleted(data) {
  console.log(`\nPayment Completed:`);
  console.log(`  Amount: ${data.attributes.currency} ${data.attributes.amount}`);
  console.log(`  Recipient: ${data.attributes.recipientName}`);
  console.log(`  Method: ${data.attributes.method}`);
  // Your business logic here
  // - Update accounting system
  // - Send confirmation email
  // - Trigger payment notifications
}
function handleAssetCreated(data) {
  console.log(`\nAsset Created:`);
  console.log(`  Title: ${data.attributes.title}`);
  console.log(`  Artists: ${data.attributes.artists.join(', ')}`);
  console.log(`  ISRC: ${data.attributes.isrc}`);
  // Your business logic here
  // - Sync to external catalog
  // - Trigger metadata enrichment
  // - Update content management system
}
function handleRoyaltyFileProcessed(data) {
  console.log(`\nRoyalty File Processed:`);
  console.log(`  Records: ${data.attributes.recordsProcessed}`);
  console.log(`  Total Amount: $${data.attributes.totalAmount}`);
  // Your business logic here
  // - Trigger accounting calculations
  // - Generate reports
  // - Send notifications to stakeholders
}
// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'ok', service: 'royalti-webhook-handler' });
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook server listening on port ${PORT}`);
  console.log(`Webhook endpoint: http://localhost:${PORT}/webhooks/royalti`);
});

Idempotency Implementation

const express = require('express');
const app = express();
// In-memory store (use Redis in production)
const processedEvents = new Set();
const EVENT_TTL = 24 * 60 * 60 * 1000; // 24 hours
app.post('/webhooks/royalti', async (req, res) => {
  const event = req.body;
  const eventId = event.id;
  // Check if we've already processed this event
  if (processedEvents.has(eventId)) {
    console.log(`Duplicate event ${eventId} - returning 200 without processing`);
    return res.status(200).json({ received: true, duplicate: true });
  }
  try {
    // Process the event
    await processEvent(event);
    // Mark as processed
    processedEvents.add(eventId);
    // Clean up old events after TTL
    setTimeout(() => {
      processedEvents.delete(eventId);
    }, EVENT_TTL);
    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Processing failed:', error);
    res.status(500).json({ error: 'Processing failed' });
  }
});
async function processEvent(event) {
  // Your event processing logic
  console.log(`Processing event: ${event.event}`);
  // Simulate async operation
  await new Promise(resolve => setTimeout(resolve, 100));
}

Troubleshooting

Common Issues

Webhooks Not Received

✅ Checklist:
  1. Is webhook-isActive set to true?
  2. Is webhook-url correctly configured with HTTPS?
  3. Are you subscribed to the event type (webhook-enabledEvents)?
  4. Check webhook delivery logs for error details
  5. Verify your endpoint is publicly accessible
  6. Check firewall/security group rules

Signature Verification Failures

✅ Checklist:
  1. Are you using the raw request body (not parsed JSON)?
  2. Is your secret token correct?
  3. Are you using the same algorithm (HMAC-SHA256)?
  4. Check for character encoding issues
  5. Verify timing-safe comparison is used
Debug Example:
console.log('Received signature:', req.headers['x-royalti-signature']);
console.log('Raw body:', req.rawBody);
console.log('Secret:', process.env.ROYALTI_WEBHOOK_SECRET);
const expectedSignature = 'sha256=' + crypto
  .createHmac('sha256', process.env.ROYALTI_WEBHOOK_SECRET)
  .update(req.rawBody)
  .digest('hex');
console.log('Expected signature:', expectedSignature);
console.log('Match:', expectedSignature === req.headers['x-royalti-signature']);

Timeout Errors

✅ Solutions:
  1. Optimize your webhook handler (should respond in <5 seconds)
  2. Process events asynchronously (queue for background processing)
  3. Increase webhook-timeoutMs if legitimately needed
  4. Return 200 immediately, then process in background
Background Processing Pattern:
const queue = [];
app.post('/webhooks/royalti', (req, res) => {
  // Verify signature first
  if (!verifySignature(...)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  // Add to queue for background processing
  queue.push(req.body);
  // Respond immediately
  res.status(200).json({ received: true });
});
// Process queue in background
setInterval(() => {
  while (queue.length > 0) {
    const event = queue.shift();
    processEvent(event).catch(console.error);
  }
}, 1000);

High Failure Rate

✅ Investigation Steps:
  1. Check delivery logs for error codes
  2. Monitor your endpoint’s error logs
  3. Verify endpoint uptime/availability
  4. Check for rate limiting on your server
  5. Review error distribution by event type
Query Recent Failures:
curl -X GET "https://server26-dot-royalti-project.uc.r.appspot.com/webhook-deliveries?status=failed&page=1&size=50" \
  -H "Authorization: Bearer YOUR_TOKEN"

Best Practices

✅ Production Deployment
  1. Always enable signature validation in production
  2. Use HTTPS for webhook endpoints (enforced by default)
  3. Respond quickly (<5 seconds) to prevent timeouts
  4. Process async - queue events for background processing
  5. Implement idempotency to handle duplicate events safely
  6. Log everything - delivery ID, event type, processing time
  7. Monitor delivery success rates via analytics API
  8. Set up alerts for high failure rates (>5%)
  9. Test thoroughly using ngrok in development
  10. Handle all event types gracefully (even unknown ones)
⚠️ Security Requirements
  1. Never log webhook payloads containing sensitive data
  2. Validate all webhook data before processing
  3. Use environment variables for secrets
  4. Implement rate limiting on your webhook endpoint
  5. Monitor for abnormal patterns (sudden spikes, unusual sources)
  6. Rotate secrets regularly (quarterly recommended)
  7. Use timing-safe comparison for signature verification
  8. Sanitize data before database insertion
  9. Implement IP whitelisting if possible
  10. Keep dependencies updated for security patches

Support & Resources

API Documentation: External Resources:

FAQ

No, webhooks are only sent for events that occur AFTER your webhook is configured and enabled. Historical events are not sent retroactively.
Royalti retries failed deliveries based on your webhook-retryAttempts configuration (default: 3 attempts) with exponential backoff starting at 2 seconds. After exhausting all retries, the delivery is marked as failed and can be manually retried via API.
Currently, you can only configure one webhook URL per tenant. All subscribed event types are sent to the same endpoint. You can filter events in your webhook handler based on the event field.
Failed deliveries are retried automatically with exponential backoff. After max retries are exhausted, deliveries are logged as failed and can be viewed in the webhook deliveries API. If 50+ consecutive failures occur, the webhook is automatically disabled to prevent resource waste.
Use ngrok or a similar tunneling service to expose your local development server to the internet. Configure your webhook URL to the ngrok HTTPS URL and trigger events in Royalti to receive webhooks locally.
Webhooks are generally sent in the order events occur, but delivery order is not guaranteed due to retries and network variations. Implement idempotency and use event timestamps to handle out-of-order delivery.
Yes, set webhook-isActive to false via the Tenant Settings API. Events that occur while webhooks are disabled will NOT be queued - they are lost permanently.
Update the webhook-secretToken setting with a new secure random value via the Tenant Settings API. Update your webhook endpoint code with the new secret before rotating to prevent signature verification failures.

Last Updated: January 2025 Version: 1.0