> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.royalti.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Integration Guide

> Complete guide to implementing webhooks with Royalti.io - both receiving notifications from Royalti and sending updates to Royalti

## 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

<Steps>
  <Step title="Configure Webhook URL">
    Set your webhook endpoint URL via Tenant Settings API:

    ```bash theme={null}
    curl -X PUT https://api.royalti.io/tenant/settings/webhook-url \
      -H "Authorization: Bearer YOUR_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"value": "https://your-domain.com/webhooks/royalti"}'
    ```
  </Step>

  <Step title="Enable Webhooks">
    Activate webhook delivery:

    ```bash theme={null}
    curl -X PUT https://api.royalti.io/tenant/settings/webhook-isActive \
      -H "Authorization: Bearer YOUR_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"value": "true"}'
    ```
  </Step>

  <Step title="Subscribe to Events">
    Choose which events to receive (comma-separated):

    ```bash theme={null}
    curl -X PUT https://api.royalti.io/tenant/settings/webhook-enabledEvents \
      -H "Authorization: Bearer YOUR_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"value": "PAYMENT_COMPLETED,ASSET_CREATED,ROYALTY_FILE_PROCESSED"}'
    ```
  </Step>

  <Step title="Implement Endpoint">
    Create an HTTP endpoint that accepts POST requests:

    ```javascript theme={null}
    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 });
    });
    ```
  </Step>
</Steps>

***

### 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 Type                 | Description                    | Payload Highlights                        |
| :------------------------- | :----------------------------- | :---------------------------------------- |
| `PAYMENT_COMPLETED`        | Payment successfully processed | `amount`, `currency`, `recipientId`       |
| `PAYMENT_PROCESSING`       | Payment in progress            | `amount`, `status`, `estimatedCompletion` |
| `PAYMENT_MADE_FAILED`      | Payment failed                 | `amount`, `errorCode`, `errorMessage`     |
| `PAYMENT_REQUEST_SENT`     | Payment request created        | `requestId`, `amount`, `recipientEmail`   |
| `PAYMENT_REQUEST_APPROVED` | Payment request approved       | `requestId`, `approvedBy`, `approvedAt`   |
| `PAYMENT_REQUEST_REJECTED` | Payment request rejected       | `requestId`, `rejectedBy`, `reason`       |
| `PAYMENT_DELETED`          | Payment record deleted         | `paymentId`, `deletedBy`                  |
| `REVENUE_CREATED`          | Revenue entry created          | `amount`, `source`, `period`              |
| `REVENUE_UPDATED`          | Revenue entry updated          | `revenueId`, `changes`, `previousAmount`  |
| `REVENUE_DELETED`          | Revenue entry deleted          | `revenueId`, `amount`, `reason`           |
| `EXPENSE_CREATED`          | Expense entry created          | `amount`, `category`, `description`       |
| `EXPENSE_UPDATED`          | Expense entry updated          | `expenseId`, `changes`                    |
| `EXPENSE_DELETED`          | Expense entry deleted          | `expenseId`, `amount`                     |

#### Catalog Events (Default Enabled)

| Event Type        | Description               | Payload Highlights                            |
| :---------------- | :------------------------ | :-------------------------------------------- |
| `ASSET_CREATED`   | New asset added           | `assetId`, `title`, `artists`, `mediaType`    |
| `ASSET_UPDATED`   | Asset modified            | `assetId`, `changes`, `previous`              |
| `ASSET_DELETED`   | Asset removed             | `assetId`, `title`, `deletedBy`               |
| `PRODUCT_CREATED` | New product/album created | `productId`, `title`, `assets`, `releaseDate` |
| `PRODUCT_UPDATED` | Product modified          | `productId`, `changes`, `previous`            |
| `PRODUCT_DELETED` | Product removed           | `productId`, `title`                          |

#### Roster Events (Default Enabled)

| Event Type             | Description                       | Payload Highlights                       |
| :--------------------- | :-------------------------------- | :--------------------------------------- |
| `USER_CREATED`         | New user added to workspace       | `userId`, `email`, `role`, `name`        |
| `USER_UPDATED`         | User profile/permissions modified | `userId`, `changes`, `updatedBy`         |
| `USER_DELETED`         | User removed from workspace       | `userId`, `email`, `deletedBy`           |
| `USER_INVITATION_SENT` | User invited to workspace         | `inviteId`, `email`, `role`, `invitedBy` |
| `ARTIST_CREATED`       | New artist profile created        | `artistId`, `name`, `genres`             |
| `ARTIST_UPDATED`       | Artist profile modified           | `artistId`, `changes`                    |
| `ARTIST_DELETED`       | Artist profile removed            | `artistId`, `name`                       |

#### Royalty Events (Require Explicit Opt-in)

High-volume events that require explicit opt-in to prevent overwhelming webhooks:

| Event Type                       | Description                       | Payload Highlights                                      |
| :------------------------------- | :-------------------------------- | :------------------------------------------------------ |
| `ROYALTY_FILE_UPLOADED`          | Royalty file uploaded             | `fileId`, `fileName`, `source`, `uploadedBy`            |
| `ROYALTY_FILE_PROCESSED`         | Royalty file processing completed | `fileId`, `recordsProcessed`, `totalAmount`, `duration` |
| `ROYALTY_FILE_PROCESSING_FAILED` | Royalty file processing failed    | `fileId`, `errorCode`, `errorMessage`, `failedAt`       |

#### Split Events (Require Signature Validation)

| Event Type                | Description                 | Payload Highlights                            |
| :------------------------ | :-------------------------- | :-------------------------------------------- |
| `USER_ADDED_TO_SPLIT`     | User added to revenue split | `splitId`, `userId`, `percentage`, `resource` |
| `USER_REMOVED_FROM_SPLIT` | User removed from split     | `splitId`, `userId`, `removedBy`, `reason`    |

***

### Webhook Payload Structure

All outbound webhooks follow this standardized structure:

```json theme={null}
{
  "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

| Field                  | Type     | Description                                       |
| :--------------------- | :------- | :------------------------------------------------ |
| `id`                   | string   | Unique webhook delivery ID (format: `whd_{uuid}`) |
| `event`                | string   | Event type from NotificationType enum             |
| `timestamp`            | ISO 8601 | When the event occurred                           |
| `version`              | string   | Webhook payload schema version (currently "1.0")  |
| `tenant.id`            | integer  | Your tenant/workspace ID                          |
| `tenant.name`          | string   | Your workspace name                               |
| `tenant.domain`        | string   | Your workspace domain                             |
| `source.service`       | string   | Service that generated the event                  |
| `source.environment`   | string   | Environment (production, staging, development)    |
| `source.traceId`       | string   | Request correlation ID for debugging              |
| `data.event`           | object   | Event metadata (ID, type, category, importance)   |
| `data.actor`           | object   | Who/what triggered the event                      |
| `data.resource`        | object   | The resource affected by the event                |
| `data.attributes`      | object   | Event-specific data (varies by event type)        |
| `data.previous`        | object   | Previous state (for update events only)           |
| `delivery.attempt`     | integer  | Current delivery attempt (1-based)                |
| `delivery.maxAttempts` | integer  | Maximum retry attempts configured                 |
| `delivery.nextRetryAt` | ISO 8601 | When next retry will occur (null if no retry)     |

***

### Webhook Configuration

Configure your webhook behavior through Tenant Settings:

#### Available Settings

| Setting Name                        | Type    | Default                | Description                                                     |
| :---------------------------------- | :------ | :--------------------- | :-------------------------------------------------------------- |
| `webhook-url`                       | string  | -                      | Your webhook endpoint URL (must be HTTPS)                       |
| `webhook-isActive`                  | boolean | false                  | Enable/disable webhook delivery                                 |
| `webhook-enabledEvents`             | string  | -                      | Comma-separated list of event types or "all"                    |
| `webhook-enableSignatureValidation` | boolean | false                  | Enable HMAC-SHA256 signature validation                         |
| `webhook-secretToken`               | string  | -                      | Secret token for signature generation (auto-generated if empty) |
| `webhook-retryAttempts`             | number  | 3                      | Number of retry attempts (1-10)                                 |
| `webhook-timeoutMs`                 | number  | 30000                  | Request timeout in milliseconds (1000-120000)                   |
| `webhook-requireHttps`              | boolean | true                   | Require HTTPS URLs (security best practice)                     |
| `webhook-customHeaders`             | string  | -                      | JSON object of custom headers to send                           |
| `webhook-userAgent`                 | string  | "Royalti-Webhooks/1.0" | Custom User-Agent header                                        |

#### Example: Complete Webhook Configuration

```bash theme={null}
# Set webhook URL
curl -X PUT https://api.royalti.io/tenant/settings/webhook-url \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "https://your-domain.com/webhooks/royalti"}'
# Enable webhooks
curl -X PUT https://api.royalti.io/tenant/settings/webhook-isActive \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "true"}'
# Subscribe to specific events
curl -X PUT https://api.royalti.io/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://api.royalti.io/tenant/settings/webhook-enableSignatureValidation \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "true"}'
# Set secret token (use a strong, random value)
curl -X PUT https://api.royalti.io/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://api.royalti.io/tenant/settings/webhook-retryAttempts \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"value": "5"}'
# Set timeout (45 seconds)
curl -X PUT https://api.royalti.io/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

```bash theme={null}
curl -X GET "https://api.royalti.io/webhook-deliveries?page=1&size=20&status=failed" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Response:**

```json theme={null}
{
  "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

```bash theme={null}
curl -X GET "https://api.royalti.io/webhook-deliveries/summary" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Response:**

```json theme={null}
{
  "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

```bash theme={null}
curl -X GET "https://api.royalti.io/webhook-deliveries/stats?timeframe=7d" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

#### Retry Failed Delivery

```bash theme={null}
curl -X POST "https://api.royalti.io/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

<Warning>
  **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.
</Warning>

### 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 Code          | Meaning      | Royalti's Action                                   |
| :------------------- | :----------- | :------------------------------------------------- |
| 200-299              | Success      | Marks delivery as successful, no retry             |
| 408, 504             | Timeout      | Marks as timeout, retries with exponential backoff |
| 400-499 (except 408) | Client error | Marks as failed, logs error, no retry              |
| 500-599 (except 504) | Server error | Marks 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 Code      | Description           | Common Causes                                  |
| :-------------- | :-------------------- | :--------------------------------------------- |
| `TIMEOUT`       | Request timed out     | Endpoint too slow, network issues              |
| `NETWORK_ERROR` | Connection failed     | DNS failure, connection refused, socket errors |
| `CLIENT_ERROR`  | 4xx status code       | Invalid request, authentication failure        |
| `SERVER_ERROR`  | 5xx status code       | Internal server error on your endpoint         |
| `SSL_ERROR`     | SSL/TLS failure       | Invalid certificate, expired certificate       |
| `INVALID_URL`   | URL validation failed | Private IP, missing HTTPS, invalid format      |
| `UNKNOWN_ERROR` | Unclassified error    | Unexpected errors                              |

### Manual Retry

You can manually retry failed deliveries via API:

```bash theme={null}
curl -X POST "https://api.royalti.io/webhook-deliveries/whd_123/retry" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

<Note>
  Manual retries create a new delivery attempt with a 5-second delay and 1 retry attempt. The original delivery is marked as `finalAttempt: false`.
</Note>

***

## Testing & Development

### Local Testing with ngrok

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

<Steps>
  <Step title="Start your local server">
    ```bash theme={null}
    node server.js
    # Server running on http://localhost:3000
    ```
  </Step>

  <Step title="Start ngrok tunnel">
    ```bash theme={null}
    ngrok http 3000
    ```

    Copy the HTTPS URL provided (e.g., `https://abc123.ngrok.io`)
  </Step>

  <Step title="Configure webhook URL">
    ```bash theme={null}
    curl -X PUT https://api.royalti.io/tenant/settings/webhook-url \
      -H "Authorization: Bearer YOUR_TOKEN" \
      -d '{"value": "https://abc123.ngrok.io/webhooks/royalti"}'
    ```
  </Step>

  <Step title="Trigger test events">
    Create an asset, make a payment, or perform other actions in Royalti to trigger webhook events.
  </Step>
</Steps>

### Test Payload Examples

#### Payment Completed Event

```json theme={null}
{
  "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

```json theme={null}
{
  "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:**

```json theme={null}
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "tenant": 123
}
```

**Example:**

```javascript theme={null}
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://api.royalti.io/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:**

```json theme={null}
{
  "id": "d3f2c1b0-1234-5678-90ab-cdef12345678",
  "status": "done",
  "url": "https://storage.googleapis.com/downloads/export-2024-01-15.csv?signature=..."
}
```

**Example:**

```python theme={null}
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://api.royalti.io/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

<Note>
  These webhooks are handled automatically by Royalti. You don't need to configure anything - Stripe sends them directly to Royalti's webhook endpoint.
</Note>

### 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

```javascript theme={null}
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

```javascript theme={null}
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:**

```javascript theme={null}
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:**

```javascript theme={null}
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:**

```bash theme={null}
curl -X GET "https://api.royalti.io/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:**

* [API Reference](/api-reference/webhooks) - Complete API documentation for webhook endpoints

* [Webhook Deliveries API](/api-reference/webhook-deliveries) - Monitor and manage webhook deliveries

**External Resources:**

* [Support Portal](https://support.royalti.io) - Get help from our support team

* [Status Page](https://status.royalti.io) - Check system status and uptime

***

## FAQ

<AccordionGroup>
  <Accordion title="Can I receive webhooks for events that occurred before I configured my webhook?">
    No, webhooks are only sent for events that occur AFTER your webhook is configured and enabled. Historical events are not sent retroactively.
  </Accordion>

  <Accordion title="How long does Royalti retry failed webhook deliveries?">
    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.
  </Accordion>

  <Accordion title="Can I configure different webhook URLs for different event types?">
    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.
  </Accordion>

  <Accordion title="What happens if my webhook endpoint is down?">
    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.
  </Accordion>

  <Accordion title="How do I test webhooks in development?">
    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.
  </Accordion>

  <Accordion title="Are webhooks sent in order?">
    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.
  </Accordion>

  <Accordion title="Can I disable webhooks temporarily?">
    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.
  </Accordion>

  <Accordion title="How do I rotate my webhook secret token?">
    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.
  </Accordion>
</AccordionGroup>

***

**Last Updated:** January 2025

**Version:** 1.0
