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

# Webhooks

> Real-time event notifications for retrieval requests

## Overview

Webhooks allow you to receive real-time HTTP notifications when events occur in your Enterprise API v2 account. Configure a webhook endpoint to be notified when retrieval requests have a public status update, need action from your firm, or have new records or invoices.

## Webhook Events

Subscribe to any combination of these event types:

| Event | Description | When Triggered |
| - | - | - |
| `retrieval_request.status_changed` | Public retrieval status or timeline changed | Any change visible in the public status or timeline, including updates within the same stage |
| `retrieval_request.action_required` | Your firm needs to act on a retrieval request | When a client action request is opened or reopened on a retrieval request |
| `retrieval_request.documents_available` | New retrieval documents are available | When records are released or a provider invoice becomes downloadable; see [Invoice Availability Notifications](/guides/v2/retrieval-guide#invoice-availability-notifications) |

When creating or updating a webhook, provide at least one event in `events`. See
[Retrieval Request Events](#retrieval-request-events) for details of the retrieval
events.

## Creating a Webhook

Register a webhook endpoint with the events you want to receive:

```bash theme={null}
curl -X POST https://enterprise.tavrn.ai/v2/webhooks \
  -H "Authorization: Bearer tavrn_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production webhook",
    "url": "https://your-app.com/webhooks/tavrn",
    "events": ["retrieval_request.status_changed", "retrieval_request.action_required", "retrieval_request.documents_available"],
    "description": "Production webhook for retrieval events"
  }'
```

```python theme={null}
import os
import requests

response = requests.post(
    "https://enterprise.tavrn.ai/v2/webhooks",
    headers={"Authorization": f"Bearer {os.environ['TAVRN_API_KEY']}"},
    json={
        "name": "Production webhook",
        "url": "https://your-app.com/webhooks/tavrn",
        "events": ["retrieval_request.status_changed", "retrieval_request.action_required", "retrieval_request.documents_available"],
        "description": "Production webhook for retrieval events"
    }
)
response.raise_for_status()

webhook = response.json()
webhook_secret = webhook["secret"]  # STORE THIS SECURELY!
```

**Response:**

```json theme={null}
{
  "id": "507f1f77bcf86cd799439016",
  "name": "Production webhook",
  "url": "https://your-app.com/webhooks/tavrn",
  "description": "Production webhook for retrieval events",
  "events": ["retrieval_request.status_changed", "retrieval_request.action_required", "retrieval_request.documents_available"],
  "secret": "whsec_XyZ789AbCdEfGhIjKlMnOpQrSt",
  "is_enabled": true,
  "auto_disabled_at": null,
  "last_failure": null,
  "created_at": "2025-02-01T12:34:56Z"
}
```

**Limits:** `name` is at most 100 characters, `description` at most 500 and `url` at most 2048.
An organization can register up to 20 webhooks; creating a 21st returns `409 limit_exceeded`
(delete or reuse an existing webhook instead). Values over a limit return `400 invalid_request`.

<Warning>
  The `secret` is **only returned once** during webhook creation. Store it securely - you'll need it to verify webhook signatures.
</Warning>

## Webhook Payload

All webhook deliveries use HTTP POST with a JSON body in this envelope. The shape of `data` depends on `type`:

```json theme={null}
{
  "id": "rr-06a28fdc7a8abfa2a1c98dd0562020ebb41ca740377930a736854d23a6c9881a",
  "type": "retrieval_request.status_changed",
  "created_at": "2025-02-01T14:22:10Z",
  "data": {
    "retrieval_request_id": "507f1f77bcf86cd799439011",
    "case_id": "507f1f77bcf86cd799439012",
    "public_updated_at": "2025-02-01T14:22:10Z"
  }
}
```

### Payload Fields

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique event ID (for deduplication) |
| `type` | string | Event type (e.g., `retrieval_request.status_changed`) |
| `created_at` | string | Event timestamp (ISO 8601) |
| `data` | object | Event-specific payload |

The `data` fields for each event type are listed under [Retrieval Request Events](#retrieval-request-events).

### Delivery Headers

| Header | Description |
| - | - |
| `X-Webhook-Event-Id` | Same value as the body's `id` |
| `X-Webhook-Event-Type` | Same value as the body's `type` |
| `X-Tavrn-Timestamp`, `X-Tavrn-Signature-V1` | Timestamped signature. See [Signature Verification](#signature-verification) |
| `X-Tavrn-Signature` | Signature without a timestamp. See [Signature Verification](#signature-verification) |

## Retrieval Request Events

Retrieval webhooks identify the request and the type of update. Fetch the request for its current status, action request, and document metadata; use the [document download endpoint](/api-reference/v2/endpoint/retrieval-requests/download-retrieval-document) for record and invoice file URLs.

The envelope is `id`, `type`, `created_at`, and `data`. Every retrieval event includes `data.retrieval_request_id`, `data.case_id` (nullable), and `data.public_updated_at`, the version of public state that triggered the event. The `id` is opaque; use it to deduplicate deliveries.

To subscribe to retrieval events, use an API key with `webhooks:write` and
`retrievals:read`.

### `retrieval_request.status_changed`

Fires when the public status or timeline changes, including a new visible update
within the same stage, a correction to an existing visible update, and a return
to a previously visited stage.

```json theme={null}
{
  "id": "rr-06a28fdc7a8abfa2a1c98dd0562020ebb41ca740377930a736854d23a6c9881a",
  "type": "retrieval_request.status_changed",
  "created_at": "2026-10-08T12:00:00.000Z",
  "data": {
    "retrieval_request_id": "507f1f77bcf86cd799439011",
    "case_id": "507f1f77bcf86cd799439012",
    "public_updated_at": "2026-10-08T12:00:00.000Z"
  }
}
```

### `retrieval_request.action_required`

Fires when a client action request is opened or reopened. Fetch [`GET /v2/retrieval-requests/{id}`](/api-reference/v2/endpoint/retrieval-requests/get-retrieval-request) and read `pending_action_request` for the current message and any attachment.

```json theme={null}
{
  "id": "rr-ddaedfb05beec0110e28fe60e7b2ca13263537bda92745c46138c95ff3c1c64a",
  "type": "retrieval_request.action_required",
  "created_at": "2026-10-08T12:01:00.000Z",
  "data": {
    "retrieval_request_id": "507f1f77bcf86cd799439011",
    "case_id": "507f1f77bcf86cd799439012",
    "public_updated_at": "2026-10-08T12:01:00.000Z",
    "action_request_id": "507f1f77bcf86cd799439013"
  }
}
```

### `retrieval_request.documents_available`

Fires when records are released or a provider invoice becomes downloadable. `document_ids` use
the same IDs as [`GET /v2/retrieval-requests/{id}/documents`](/api-reference/v2/endpoint/retrieval-requests/list-retrieval-documents).
An invoice event also contains `invoice_ids` with the same ID in both arrays. Fetch document metadata through the API, then request a download URL from the
[document download endpoint](/api-reference/v2/endpoint/retrieval-requests/download-retrieval-document).

```json theme={null}
{
  "id": "rr-712843b65fb7c98c76a4e3c2fce72598b835ba9b14cf532803c729f9367fe928",
  "type": "retrieval_request.documents_available",
  "created_at": "2026-10-08T12:02:00.000Z",
  "data": {
    "retrieval_request_id": "507f1f77bcf86cd799439011",
    "case_id": "507f1f77bcf86cd799439012",
    "public_updated_at": "2026-10-08T12:02:00.000Z",
    "document_ids": ["507f1f77bcf86cd799439014"]
  }
}
```

An invoice may appear in the request and document list before its file is ready. This event marks when it becomes downloadable. [Incremental polling](/guides/v2/retrieval-guide#incremental-polling) keeps other request changes current.

For a downloadable provider invoice, both arrays contain its invoice ID:

```json theme={null}
{
  "id": "rr-1027bfb56ac889adef3b3b8266d758322502c10b6b64499224218c9e3e2919f0",
  "type": "retrieval_request.documents_available",
  "created_at": "2026-10-08T12:03:00.000Z",
  "data": {
    "retrieval_request_id": "507f1f77bcf86cd799439011",
    "case_id": "507f1f77bcf86cd799439012",
    "public_updated_at": "2026-10-08T12:03:00.000Z",
    "document_ids": ["507f1f77bcf86cd799439015"],
    "invoice_ids": ["507f1f77bcf86cd799439015"]
  }
}
```

### Event IDs and reconciliation

Deduplicate deliveries by event `id`. Retries use the same ID; a later occurrence, such as reopening an action request, has a new ID.

When an event arrives, fetch the identified request and compare its `public_updated_at` with your cached version. Events may arrive out of order, so the fetched request gives you the current state.

Use [incremental polling](/guides/v2/retrieval-guide#incremental-polling) to keep all requests current, including completed and cancelled ones. Save the final page's `next_public_updated_since` as your polling checkpoint, and choose an interval that fits your integration.

Fetch [`GET /v2/retrieval-requests/{id}`](/api-reference/v2/endpoint/retrieval-requests/get-retrieval-request) when you need current costs.

## Signature Verification

Every webhook delivery includes the recommended timestamped `X-Tavrn-Signature-V1` signature and the `X-Tavrn-Timestamp` header. Verify the v1 signature against the **raw request body** and reject timestamps more than five minutes from your current time. The timestamp limits replay attacks.

The v1 signature is the hex HMAC-SHA256 of `<timestamp>.<raw body>`, sent as `X-Tavrn-Signature-V1: v1=<hex>`. The timestamp is Unix time in seconds (for example, `1767225600`). Compare signatures in constant time.

`X-Tavrn-Signature` signs only the raw body and has no timestamp. Use the v1
signature for verification.

### Node.js Example

```javascript theme={null}
const crypto = require('crypto');
const express = require('express');

const app = express();
const webhookSecret = process.env.TAVRN_WEBHOOK_SECRET; // e.g. "whsec_test_example"

app.post('/webhooks/tavrn', express.raw({type: 'application/json'}), async (req, res) => {
  const signature = req.headers['x-tavrn-signature-v1'];
  const timestampHeader = req.headers['x-tavrn-timestamp'];
  if (typeof signature !== 'string' || !/^v1=[0-9a-f]{64}$/i.test(signature) ||
      typeof timestampHeader !== 'string' || !/^\d+$/.test(timestampHeader)) {
    return res.status(401).send('Invalid signature');
  }

  const timestamp = Number(timestampHeader);
  if (!Number.isSafeInteger(timestamp) || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) {
    return res.status(401).send('Expired timestamp');
  }

  const expectedSignature = crypto.createHmac('sha256', webhookSecret)
    .update(`${timestamp}.`)
    .update(req.body)
    .digest();
  const receivedSignature = Buffer.from(signature.slice(3), 'hex');

  if (receivedSignature.length !== expectedSignature.length ||
      !crypto.timingSafeEqual(receivedSignature, expectedSignature)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  try {
    // Implement this with a durable queue or database and a unique event ID.
    await enqueueEventOnce(event.id, event);
  } catch {
    return res.status(503).send('Unable to save event');
  }

  return res.status(202).send('Accepted');
});
```

### Python Example

```python theme={null}
import os
import re
import hmac
import hashlib
import time
from flask import Flask, request

app = Flask(__name__)
webhook_secret = os.environ['TAVRN_WEBHOOK_SECRET']  # e.g. "whsec_test_example"

@app.route('/webhooks/tavrn', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Tavrn-Signature-V1', '')
    timestamp_header = request.headers.get('X-Tavrn-Timestamp', '')
    if not re.fullmatch(r'v1=[0-9a-f]{64}', signature) or not re.fullmatch(r'[0-9]{1,15}', timestamp_header):
        return 'Invalid signature', 401

    timestamp = int(timestamp_header)
    if abs(int(time.time()) - timestamp) > 300:
        return 'Expired timestamp', 401

    expected_signature = hmac.new(
        webhook_secret.encode(),
        str(timestamp).encode() + b'.' + request.get_data(),
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature.encode(), ('v1=' + expected_signature).encode()):
        return 'Invalid signature', 401

    # Implement enqueue_event_once with a durable queue or database.
    event = request.json
    try:
        enqueue_event_once(event['id'], event)
    except Exception:
        return 'Unable to save event', 503

    return 'Accepted', 202
```

Implement `enqueueEventOnce` or `enqueue_event_once` in your application so it stores an event once by `id`. Process the saved event in a background worker.

For Express, register the raw-body webhook route before any JSON body parser middleware so signature verification receives the original bytes.

## Best Practices

### Respond Quickly

Verify the signature and save the event durably before returning a 2xx response within 5 seconds. Do longer work, such as fetching a request or downloading a file, in a background worker. If the event cannot be saved, return a non-2xx response so Tavrn retries it.

### Idempotent Processing

Events may be delivered more than once. A retried delivery has the same `id` as the original. Store processed IDs in a persistent queue or database so deduplication survives restarts, and make the background work safe to retry.

### Handle All Event Types

Handle unknown event types gracefully in your background worker:

```javascript theme={null}
async function processSavedEvent(event) {
  switch (event.type) {
    case 'retrieval_request.status_changed':
    case 'retrieval_request.action_required':
      // Fetch GET /v2/retrieval-requests/{id} for current state.
      await syncRetrievalRequest(event.data.retrieval_request_id);
      break;
    case 'retrieval_request.documents_available':
      // Fetch current request details and process each matching document ID.
      await syncRetrievalRequest(
        event.data.retrieval_request_id,
        event.data.document_ids,
      );
      break;
    case 'webhook.test':
      // Sent by POST /v2/webhooks/{id}/test
      break;
    default:
      console.log(`Unknown event type: ${event.type}`);
  }
}
```

## Retries and Automatic Disabling

A delivery succeeds when your endpoint returns a 2xx status within 5 seconds. Any other status, a timeout, or a connection error counts as a failed attempt. Tavrn does not follow redirects, so a 3xx response is a failed attempt too: your webhook URL must answer directly. Tavrn makes up to 5 attempts per event, retrying 1 minute, 5 minutes, 30 minutes, and 2 hours after successive failures. Every attempt carries the same event body and `id`; the timestamp and v1 signature are generated for each attempt.

If all attempts for an event fail, the webhook's `consecutive_failures` increases by one; any successful delivery resets it to `0`. When `consecutive_failures` reaches 5, Tavrn disables the webhook: `is_enabled` becomes `false` and `auto_disabled_at` records when. Disabled webhooks receive no events, and events that occur while a webhook is disabled are not delivered later.

To recover, fix your endpoint, confirm it with a [test event](#testing-webhooks), then re-enable the webhook with `PATCH /v2/webhooks/{id}` and `{"is_enabled": true}`. Re-enabling resets `consecutive_failures` to `0` and clears `auto_disabled_at`. For retrieval requests, use your [reconciliation poll](#event-ids-and-reconciliation) to catch up on anything missed.

### Monitor Webhook Health

Regularly check your webhook status:

```bash theme={null}
curl https://enterprise.tavrn.ai/v2/webhooks \
  -H "Authorization: Bearer tavrn_sk_live_..."
```

Check for:

* `consecutive_failures` > 0 (investigate delivery issues)
* `is_enabled: false` (webhook is disabled)
* `auto_disabled_at` set (Tavrn disabled the webhook after 5 consecutive failed deliveries)
* `last_failure_at` / `last_success_at` (delivery timestamps)
* `last_failure` (most recent failed attempt: `at`, `status_code`, `error`, `event_type`)

```json theme={null}
{
  "last_failure": {
    "at": "2026-10-08T12:02:01.000Z",
    "status_code": 502,
    "error": "HTTP 502",
    "event_type": "retrieval_request.documents_available"
  }
}
```

`status_code` is `null` when your endpoint did not respond. `error` describes the
failure. `last_failure` excludes test events.

## Managing Webhooks

### List Webhooks

```bash theme={null}
curl https://enterprise.tavrn.ai/v2/webhooks \
  -H "Authorization: Bearer tavrn_sk_live_..."
```

### Update Webhook

```bash theme={null}
curl -X PATCH https://enterprise.tavrn.ai/v2/webhooks/507f1f77bcf86cd799439016 \
  -H "Authorization: Bearer tavrn_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["retrieval_request.status_changed", "retrieval_request.documents_available"],
    "is_enabled": true
  }'
```

Setting `is_enabled` to `true` on a disabled webhook resets `consecutive_failures` to `0` and clears `auto_disabled_at`.

### Disable Webhook

```bash theme={null}
curl -X PATCH https://enterprise.tavrn.ai/v2/webhooks/507f1f77bcf86cd799439016 \
  -H "Authorization: Bearer tavrn_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"is_enabled": false}'
```

### Delete Webhook

```bash theme={null}
curl -X DELETE https://enterprise.tavrn.ai/v2/webhooks/507f1f77bcf86cd799439016 \
  -H "Authorization: Bearer tavrn_sk_live_..."
```

## Testing Webhooks

### Send a Test Event

Send a test event to check that your endpoint is reachable and verifies signatures:

```bash theme={null}
curl -X POST https://enterprise.tavrn.ai/v2/webhooks/507f1f77bcf86cd799439016/test \
  -H "Authorization: Bearer tavrn_sk_live_..."
```

Tavrn makes one delivery attempt and returns HTTP 200 with the result, even if delivery fails. Check `success`:

```json theme={null}
{
  "success": false,
  "status_code": 502,
  "duration_ms": 81,
  "error": "HTTP 502",
  "event_id": "evt_test_6f1c2a9e-4b7d-4c1e-9a53-0d2f8e7b1c44",
  "sent_at": "2025-02-01T12:40:00.000Z"
}
```

`status_code` is `null` when no response was received. `error` is `null` on
success and describes the failure otherwise. Your endpoint's response body is
never returned.

The endpoint requires the `webhooks:write` scope and works on disabled webhooks.
Tests do not change `consecutive_failures`, `last_failure_at`, `last_success_at`,
or `last_failure`. Failed tests are not retried. You can send one test per webhook
every 10 seconds; sooner requests return `429 rate_limited` with a `Retry-After`
header.

Your endpoint receives a `webhook.test` event, signed and delivered with the same headers as real events (`X-Webhook-Event-Type: webhook.test`):

```json theme={null}
{
  "id": "evt_test_6f1c2a9e-4b7d-4c1e-9a53-0d2f8e7b1c44",
  "type": "webhook.test",
  "created_at": "2025-02-01T12:40:00.000Z",
  "data": {
    "webhook_id": "507f1f77bcf86cd799439016"
  }
}
```

`webhook.test` cannot be added to `events`. Return a 2xx response and take no other action.

### Local Development

For local development, use a tool like [ngrok](https://ngrok.com/) to expose your local server:

```bash theme={null}
# Terminal 1: Start your local server
npm start

# Terminal 2: Create ngrok tunnel
ngrok http 3000

# Use the ngrok URL when creating webhooks
curl -X POST https://enterprise.tavrn.ai/v2/webhooks \
  -H "Authorization: Bearer tavrn_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Local development",
    "url": "https://abc123.ngrok.io/webhooks/tavrn",
    "events": ["retrieval_request.status_changed"]
  }'
```

## Security Considerations

1. **Verify signatures** - Check `X-Tavrn-Signature-V1` against the raw body before processing a delivery
2. **Use HTTPS** - Keep delivery encrypted. URLs must be publicly reachable and cannot resolve to a private address
3. **Rotate secrets** - If a secret is compromised, delete and recreate the webhook
4. **Rate limiting** - Protect your endpoint from abuse
5. **Logging** - Log event IDs and delivery outcomes without logging patient details, secrets, or signed download URLs

## Next Steps

* [Create your first retrieval](/guides/v2/get-started/first-retrieval)
* [Retrieve medical records](/guides/v2/retrieval-guide)
* [Handle errors gracefully](/guides/v2/errors)
* [View all webhook endpoints](/api-reference/v2/endpoint/webhooks/create-webhook)
