Source: https://docs.debitura.com/referral-partners/integration-guides/webhooks

# Webhooks

Subscribe to real-time events for collection requests, client onboarding, linking and case status changes.

<a id="which-events-you-need"></a>

## Which events you need

| Integration | Subscribe to |
| --- | --- |
| **Starter** | [`collection_request.updated`](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#collection_requestupdated), plus `case.created`, `case.updated` and `case.closed` if you show case progress |
| **Full** | `client.onboarding.contract_signed`, `client.linked`, `client.link_requested`, `client.link_expired`, `case.created`, `case.updated`, `case.closed` |

Subscriptions, signatures, retries and deduplication work the same way for both. See [Production practices](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#production-practices) below, and [test your handler](https://docs.debitura.com/referral-partners/integration-guides/webhook-testing.md) before going live.

<a id="create-a-webhook-subscription"></a>

## Create a Webhook Subscription

```http
POST https://referral-api.debitura.com/v1/Webhooks
Content-Type: application/json
XApiKey: your_partner_api_key_here

{
  "Url": "https://your-platform.com/webhooks/debitura",
  "Events": [
    "client.onboarding.poa_signed",
    "client.onboarding.contract_signed",
    "client.linked",
    "client.link_declined",
    "client.link_requested",
    "client.link_expired",
    "case.created",
    "case.updated",
    "case.closed",
    "cases.replay_failed",
    "collection_request.updated"
  ],
  "IsTestMode": false
}
```

**Request body parameters:**

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `Url` | string | — | The HTTPS endpoint to deliver events to |
| `Events` | string\[\] | — | Event types to subscribe to (see [Available Events](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#available-events)) |
| `IsTestMode` | boolean | `false` | When `true`, this subscription only receives webhook events from test cases. When `false` (default), it receives events from live production cases. Can be toggled later via `PUT /v1/Webhooks/{id}`, which re-classifies the subscription between Test and Production. |

Test mode

During integration development, set `"IsTestMode": true` to receive events from test cases only — your live production data is unaffected. See [Test vs Live subscriptions](https://docs.debitura.com/developer-concepts/webhooks.md#test-vs-live-subscriptions) for details.

Response (`200 OK`):

```json
{
  "Id": "550e8400-e29b-41d4-a716-446655440000",
  "Url": "https://your-platform.com/webhooks/debitura",
  "Events": ["client.linked", "case.created", "case.updated", "case.closed"],
  "IsActive": true,
  "IsTestMode": false,
  "CreatedUtc": "2026-01-31T15:23:45Z",
  "UpdatedUtc": "2026-01-31T15:23:45Z",
  "DisabledReason": null,
  "Secret": "dGhpc2lzYWJhc2U2NGVuY29kZWRzZWNyZXQ="
}
```

Save Your Secret

The `secret` is **only returned once** during creation. Store it immediately in your secrets manager. You cannot retrieve it later. If lost, regenerate via `PUT /v1/Webhooks/{id}` with `RegenerateSecret: true`.

<a id="manage-subscriptions"></a>

## Manage Subscriptions

List all webhooks

```http
GET https://referral-api.debitura.com/v1/Webhooks
XApiKey: your_partner_api_key_here
```

Update a webhook

```http
PUT https://referral-api.debitura.com/v1/Webhooks/{id}
Content-Type: application/json
XApiKey: your_partner_api_key_here

{
  "Url": "https://new-endpoint.com/webhooks",
  "Events": ["client.linked", "case.created"],
  "IsActive": true,
  "RegenerateSecret": false
}
```

Delete a webhook

```http
DELETE https://referral-api.debitura.com/v1/Webhooks/{id}
XApiKey: your_partner_api_key_here
```

For the full API reference, see [Webhook endpoints](https://docs.debitura.com/api/referral-partner-api/webhooks.md#create-a-new-webhook-subscription).

<a id="payload-structure"></a>

## Payload Structure

All events use a CloudEvents-inspired format with these envelope fields:

| Field | Type | Description |
| --- | --- | --- |
| `id` | string (UUID) | Unique event ID for idempotency |
| `specVersion` | string | Always "1.0" |
| `event` | string | Event type (e.g., "client.linked") |
| `timestamp` | string (ISO 8601) | When the event occurred, always in UTC |
| `data` | object | Event-specific payload |
| `links.client` | string | API URL for this client |

The envelope `timestamp` is always emitted as `YYYY-MM-DDTHH:MM:SSZ` — UTC, whole seconds, no fractional part, and independent of the locale of the server that produced the event. Parse it as a fixed-format UTC instant; do not parse it with a locale-sensitive date parser.

**HTTP Headers:**

| Header | Value |
| --- | --- |
| `X-Debitura-Signature` | `t={timestamp},v1={signature}` |
| `X-Debitura-Timestamp` | Unix timestamp |

<a id="creditor-object"></a>

## Creditor Object

Client onboarding and linking events include a `creditor` object with these fields:

| Field | Type | Description |
| --- | --- | --- |
| `creditor.id` | string (UUID) | Debitura's internal creditor ID |
| `creditor.companyName` | string | Legal company name |
| `creditor.registrationNumber` | string | VAT or company registration number (e.g., "DK12345678") |
| `creditor.countryId` | integer | Country identifier |
| `creditor.isAttributed` | boolean | Whether this is an [attributed client](https://docs.debitura.com/referral-partners/concepts/attribution.md) — determines revenue share eligibility. **Not present** in `client.link_declined` events. |

<a id="available-events"></a>

## Available Events

<a id="collection_requestupdated"></a>

### collection\_request.updated

Fires when a [Starter collection request](https://docs.debitura.com/referral-partners/integration-guides/starter-integration.md#3-follow-progress) is initially submitted or its request status or invoice outcome changes. `data` is the current authorised collection-request representation, including its permitted next action; it is not a case-event or client-onboarding payload. Unchanged reads and keyed replays do not emit an update. Existing case events remain available.

A next-action change can appear in GET without a new request-update event, for example after SDCA signing when a PoA is still required. Verify signatures and deduplicate by `event.id`, then GET the affected request. After return from shared hosted setup, refresh all selected request IDs even without an event. A keyed POST replay can return its original snapshot; it is not a progress read. See [Sending several invoices](https://docs.debitura.com/referral-partners/integration-guides/starter-integration.md#sending-several-invoices).

Normal request updates follow the partner-level referral-event policy: both Test and Production subscriptions can receive them, regardless of the invoice's `isTest`. An explicit test trigger requires `collectionRequestId` and targets Test subscriptions only in the test environment. Treat delivery as repeatable and recover missed publication through current GET. The [generated reference](https://docs.debitura.com/api/referral-partner-api/collection-requests.md) owns the request model.

<a id="clientonboardingpoa_signed"></a>

### client.onboarding.poa\_signed

Fires when a PoA document is created or signed for a client. This event fires for all PoA creation paths:

-   Main onboarding wizard flow
-   Standalone PoA signing flow
-   Admin-uploaded PoA (a Debitura admin uploads a pre-signed document on behalf of the client)

client.onboarding.poa\_signed payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "client.onboarding.poa_signed",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "creditor": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "companyName": "Acme Corporation Ltd",
      "registrationNumber": "DK12345678",
      "countryId": 208,
      "isAttributed": false
    },
    "signedAt": "2026-01-31T15:23:45Z"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for this client (from onboarding init) |
| `creditor` | object | Client details — see [Creditor Object](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#creditor-object) |
| `signedAt` | string (ISO 8601) | UTC timestamp when PoA was signed |

<a id="clientonboardingcontract_signed"></a>

### client.onboarding.contract\_signed

Fires when a client signs the Standard Debt Collection Agreement (SDCA) with 2FA verification. This event fires for all SDCA signing paths:

-   Main onboarding wizard flow
-   Signing via the Creditor portal (outside the wizard)
-   Admin-triggered signing via BackendAdmin

client.onboarding.contract\_signed payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "client.onboarding.contract_signed",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "creditor": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "companyName": "Acme Corporation Ltd",
      "registrationNumber": "DK12345678",
      "countryId": 208,
      "isAttributed": false
    },
    "signedAt": "2026-01-31T15:23:45Z"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for this client |
| `creditor` | object | Client details — see [Creditor Object](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#creditor-object) |
| `signedAt` | string (ISO 8601) | UTC timestamp when contract was signed |

<a id="clientlinked"></a>

### client.linked

Fires when a client is linked to your partnership through the [409 conflict resolution flow](https://docs.debitura.com/referral-partners/integration-guides/client-onboarding-conflicts.md). This covers both paths: the user connecting an existing Debitura account and the user creating a new account from the choice page.

`creditor.isAttributed` is always `false` for this event — the user already existed in Debitura before your referral. You earn fees only on cases submitted through your API using bearer tokens. See [Attribution](https://docs.debitura.com/referral-partners/concepts/attribution.md) for details.

client.linked payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "client.linked",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "creditor": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "companyName": "Acme Corporation Ltd",
      "registrationNumber": "DK12345678",
      "countryId": 208,
      "isAttributed": false
    },
    "linkedAt": "2026-01-31T15:23:45Z"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for this client |
| `creditor` | object | Client details — see [Creditor Object](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#creditor-object). `isAttributed` is always `false` for linked clients. |
| `linkedAt` | string (ISO 8601) | UTC timestamp when link was approved |

<a id="clientlink_declined"></a>

### client.link\_declined

Fires when a link request is declined. This event fires in two situations:

-   **Manual decline**: the existing Debitura client explicitly declines your link request (resolves [409 conflict](https://docs.debitura.com/referral-partners/integration-guides/client-onboarding.md#409-conflict--client-already-exists))
-   **Auto-decline of duplicates**: when one pending link request is approved, any other pending requests for the same client are automatically declined — each fires its own `client.link_declined` event

No link is created in either case, so you cannot submit cases for this client.

client.link\_declined payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "client.link_declined",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "creditor": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "companyName": "Acme Corporation Ltd",
      "registrationNumber": "DK12345678",
      "countryId": 208
    },
    "declinedAt": "2026-01-31T15:23:45Z"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

info

The `creditor.isAttributed` field is **not present** in decline events because no link is created.

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for this client |
| `creditor` | object | Client details — see [Creditor Object](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#creditor-object). Note: `isAttributed` field is absent. |
| `declinedAt` | string (ISO 8601) | UTC timestamp when link was declined |

<a id="clientlink_requested"></a>

### client.link\_requested

Fires when a 409 carry-through triggers a link approval request to an existing Debitura client. This gives you confirmation that the approval URL was issued and the link request is now pending. Debitura does not email the approval URL, so use this event to make sure you have delivered the URL from the `409` response to the user.

client.link\_requested payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "client.link_requested",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "requestId": "9f2c1b6e-7a3d-4e21-9b0f-2d8c4a1e6f33",
    "requestedAt": "2026-01-31T15:23:45Z",
    "expiresAt": "2026-02-07T15:23:45Z"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for this client |
| `requestId` | string (UUID) | Identifier for this link request — correlate with the matching `client.link_expired` event |
| `requestedAt` | string (ISO 8601) | UTC timestamp when the link request was issued |
| `expiresAt` | string (ISO 8601) | null | When the approval URL expires if not acted on |
| `creditor` | object (optional) | Present when the matched creditor is known — see [Creditor Object](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#creditor-object). Omitted otherwise. |

<a id="clientlink_expired"></a>

### client.link\_expired

Fires when a pending link approval request expires without the client acting. Link requests expire after **7 days** by default (configurable per partner, maximum 30 days). Once you receive this event, call `POST /clients` again with the same `externalTenantId` to generate a new approval URL and restart the flow.

client.link\_expired payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "client.link_expired",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "requestId": "9f2c1b6e-7a3d-4e21-9b0f-2d8c4a1e6f33",
    "creditor": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "companyName": "Acme Corporation Ltd",
      "registrationNumber": "DK12345678",
      "countryId": 208
    },
    "expiresAt": "2026-02-07T15:23:45Z",
    "expiredAt": "2026-02-07T15:23:45Z"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for this client |
| `requestId` | string (UUID) | Identifier for the expired link request — matches the `requestId` from the originating `client.link_requested` event |
| `creditor` | object | Client details — see [Creditor Object](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#creditor-object). `isAttributed` is absent. |
| `expiresAt` | string (ISO 8601) | null | When the approval URL was set to expire |
| `expiredAt` | string (ISO 8601) | UTC timestamp when the link request expired |

<a id="casecreated"></a>

### case.created

Fires when a new collection case is created for any linked client.

case.created payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "case.created",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "caseId": "123e4567-e89b-12d3-a456-426614174000",
    "reference": "Q8OAXF3W",
    "lifecycle": "Pending contract signing",
    "creditorReference": "INV-001",
    "creditorId": "789e4567-e89b-12d3-a456-426614174222"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for the client who owns this case |
| `caseId` | string (UUID) | Debitura's internal case ID |
| `reference` | string | Human-readable case reference (e.g., "Q8OAXF3W") |
| `lifecycle` | string | Initial lifecycle state of the case at creation time — see [Lifecycle States](https://docs.debitura.com/developer-concepts/case-lifecycle.md#lifecycle-states) for all values |
| `creditorReference` | string | null | The case reference you provided in your original `POST /clients` submission (echoed back). Use this to correlate `case.created` events with your submissions — especially on the 409 path where cases are created asynchronously. Null if no reference was submitted. |
| `creditorId` | string (UUID) | Debitura's internal creditor ID for the client who owns this case |

<a id="caseupdated"></a>

### case.updated

Fires when a case lifecycle state changes for any linked client.

case.updated payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "case.updated",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "caseId": "123e4567-e89b-12d3-a456-426614174000",
    "reference": "Q8OAXF3W",
    "oldLifecycle": "Active",
    "newLifecycle": "Closed"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for the client who owns this case |
| `caseId` | string (UUID) | Debitura's internal case ID |
| `reference` | string | Human-readable case reference |
| `oldLifecycle` | string | Previous lifecycle state |
| `newLifecycle` | string | New lifecycle state |

See [Case Lifecycle](https://docs.debitura.com/developer-concepts/case-lifecycle.md#lifecycle-states) for all lifecycle states and their meanings.

<a id="caseclosed"></a>

### case.closed

Fires when a case reaches its terminal state (closed). This is the final event for a case — no further `case.updated` events will follow. Use it to show clients whether their case is finally settled and with what outcome.

case.closed payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "case.closed",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "externalTenantId": "partner-client-123",
    "caseId": "123e4567-e89b-12d3-a456-426614174000",
    "reference": "XAVSVWM7",
    "closeCode": "Paid",
    "closeComment": "Paid in full by debtor"
  },
  "links": {
    "client": "https://api.debitura.com/referral-partner/v1/clients/partner-client-123"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `externalTenantId` | string | Your identifier for the client who owns this case |
| `caseId` | string (UUID) | Debitura's internal case ID |
| `reference` | string | Human-readable case reference |
| `closeCode` | string | Reason the case was closed (e.g., "Paid", "Partially paid", "Debtor Insolvent", "Case Never Started") |
| `closeComment` | string | null | Optional additional context about the closure |

See [Case Lifecycle](https://docs.debitura.com/developer-concepts/case-lifecycle.md#close-codes) for all close codes and their meanings.

<a id="casesreplay_failed"></a>

### cases.replay\_failed

Fires when an individual case creation fails during the [409 carry-through replay](https://docs.debitura.com/referral-partners/integration-guides/client-onboarding-conflicts.md) — i.e., after the user approves the link or creates a new account on the choice page, Debitura replays the cases you submitted and one of them is rejected with a soft failure. This is a per-case event; other cases in the same batch may succeed and emit `case.created` events.

Use this event to surface failed cases to your users so they can correct and resubmit.

cases.replay\_failed payload

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "specVersion": "1.0",
  "event": "cases.replay_failed",
  "timestamp": "2026-01-31T15:23:45Z",
  "data": {
    "creditorId": "789e4567-e89b-12d3-a456-426614174abc",
    "externalTenantId": "partner-client-123",
    "creditorReference": "INV-001",
    "failureReason": "Invoice amount is below the minimum threshold",
    "attemptedAt": "2026-01-31T15:23:45Z"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `creditorId` | string (UUID) | Debitura's internal creditor ID for the client |
| `externalTenantId` | string | Your identifier for the client |
| `creditorReference` | string | null | The case reference you provided in your original submission. Use this to identify which case in your system failed. Null if no reference was provided. |
| `failureReason` | string | Human-readable explanation of why this specific case was rejected |
| `attemptedAt` | string (ISO 8601) | UTC timestamp when the creation attempt occurred |

Verify the [webhook signature](https://docs.debitura.com/developer-concepts/webhooks.md#signature-verification) before processing events. For production patterns, see [Production practices](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#production-practices). To test your handler, see [Webhook Testing](https://docs.debitura.com/referral-partners/integration-guides/webhook-testing.md).

**Business context:** See [Webhook events (business meaning glossary)](https://support.debitura.com/en/articles/13908980-referral-partners-webhook-events-business-meaning-glossary) for what each event means in business terms.

<a id="production-practices"></a>

## Production practices

Patterns for reliable webhook processing: timeouts, retries, idempotency and tenant routing.

<a id="timeout--retry-behavior"></a>

### Timeout & Retry Behavior

**Timeout:** 10 seconds. Respond within 10 seconds or delivery fails.

**Pattern:** Durably enqueue first, then acknowledge and process asynchronously. If enqueueing fails, return a failure so delivery can be retried.

Express.js webhook handler

```javascript
app.post('/webhooks/debitura', async (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  try {
    await queue.enqueue('webhook-processing', req.body); // Durable acceptance
    return res.status(200).json({ received: true });
  } catch {
    return res.status(503).json({ error: 'Please retry delivery' });
  }
});
```

For signature verification details, see [Webhook Security](https://docs.debitura.com/developer-concepts/webhooks.md#signature-verification).

<a id="retry-schedule"></a>

#### Retry Schedule

Debitura retries with exponential backoff and ~10% jitter:

| Attempt | Delay | Cumulative |
| --- | --- | --- |
| 1 | 0s | 0s |
| 2 | 60s +/- 10% | ~1 min |
| 3 | 120s +/- 10% | ~3 min |
| 4 | 240s +/- 10% | ~7 min |
| 5 | 480s +/- 10% | ~15 min |
| 6 | 960s +/- 10% | ~31 min |
| 7 | 1800s +/- 10% | ~1 hour |
| 8 | 1800s +/- 10% | ~1.5 hours |

**Maximum:** 8 attempts (1 initial delivery + 7 retries) over ~1.5 hours.

<a id="auto-disable-conditions"></a>

#### Auto-Disable Conditions

Webhooks automatically disable after:

1.  **8 consecutive failures** (all retries exhausted)
    
    -   `disabledReason`: "Exceeded maximum retry attempts (8 failures)"
    -   Fix the endpoint, then re-enable via API
2.  **410 Gone response** (any attempt)
    
    -   `disabledReason`: "Endpoint returned 410 Gone (endpoint retired)"
    -   Update the webhook URL or delete the subscription

To create or manage subscriptions, see [Create a Webhook Subscription](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#create-a-webhook-subscription).

<a id="idempotency-with-eventid"></a>

### Idempotency with event.id

Use `event.id` to prevent duplicate processing:

Idempotent webhook processing

```javascript
async function processWebhook(webhook) {
  const eventId = webhook.id;

  // Check if already processed
  const exists = await db.processedEvents.findOne({ eventId });
  if (exists) {
    console.log(`Event ${eventId} already processed`);
    return; // Safe duplicate
  }

  // Process + record atomically
  await db.transaction(async (tx) => {
    await tx.processedEvents.insert({ eventId, processedAt: new Date() });

    if (webhook.event === 'case.created') {
      await tx.cases.insert({
        caseId: webhook.data.caseId,
        externalTenantId: webhook.data.externalTenantId,
        reference: webhook.data.reference
      });
    }
  });
}
```

**Database schema:**

Event deduplication table

```sql
CREATE TABLE processed_webhook_events (
  event_id UUID PRIMARY KEY,
  processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  event_type VARCHAR(100)
);

CREATE INDEX idx_processed_events_created ON processed_webhook_events(processed_at);

-- Cleanup old events (run weekly)
DELETE FROM processed_webhook_events WHERE processed_at < NOW() - INTERVAL '30 days';
```

<a id="externaltenantid-routing"></a>

### externalTenantId Routing

All events include `data.externalTenantId`—your identifier for the client. Use it to route events to the correct tenant in your system.

Tenant-based event routing

```javascript
async function processWebhook(webhook) {
  const { event, data } = webhook;
  const { externalTenantId } = data;

  // Load client
  const client = await db.clients.findOne({ externalTenantId });
  if (!client) {
    console.error(`Unknown client: ${externalTenantId}`);
    return;
  }

  // Route by event type
  switch (event) {
    case 'case.created':
      await handleCaseCreated(client, data);
      break;
    case 'case.updated':
      await handleCaseUpdated(client, data);
      break;
    case 'client.onboarding.contract_signed':
      await handleContractSigned(client, data);
      break;
  }
}
```

For every event type and payload, see [Available Events](https://docs.debitura.com/referral-partners/integration-guides/webhooks.md#available-events). For a Starter request, route `collection_request.updated` by the request `id` you stored.
