> For the complete documentation index, see [llms.txt](https://docs.bloodgpt.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.bloodgpt.com/interpretation-and-results/webhooks.md).

# Webhooks

Receive real-time notifications when blood test processing completes or fails

{% hint style="info" %}
Webhooks provide push notifications to your server when processing completes, eliminating the need for polling the CDN.
{% endhint %}

## Overview

Instead of polling `download_url`, you can receive instant notifications when results are ready.

```mermaid
sequenceDiagram
    participant Client
    participant BloodGPT API
    participant Your Server

    Client->>BloodGPT API: POST /api/v1/process
    BloodGPT API-->>Client: 202 Accepted (test_id)

    Note right of BloodGPT API: Processing...

    BloodGPT API->>Your Server: POST (org-configured webhook URL)
    Your Server-->>BloodGPT API: 200 OK
```

## Enabling Webhooks

Webhook delivery for `/api/v1/process` is configured **at the organization level**, not per request. To enable it, register your endpoint URL and signing secret with us — contact <contact@bloodgpt.com>. Once configured, every completed (or failed) test for your organization is delivered to that URL automatically.

{% hint style="warning" %}
The `/api/v1/process` request body does **not** accept a `webhook_url` field — any such value is ignored. A per-request `webhook_url` override is only supported on the [Batch Processing](/submitting-tests/batch.md) endpoint.
{% endhint %}

For batches, pass `webhook_url` in the batch request body to override the organization default for that batch:

```json
{
  "webhook_url": "https://your-server.com/webhooks/bloodgpt",
  "items": [ ... ]
}
```

***

## Events

### processing\_completed

Sent when blood test analysis is complete and PDF report is ready.

```json
{
  "event": "processing_completed",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "data": {
    "test_id": "clxyz123456",
    "external_id": "test-123",
    "patient_id": "patient-456",
    "status": "completed",
    "pdf_url": "https://cdn.bloodgpt.com/reports/xxx.pdf?sig=...",
    "results": [
      {
        "name": "Hemoglobin",
        "value": 14.5,
        "unit": "g/dL",
        "reference_min": 12.0,
        "reference_max": 16.0,
        "is_healthy": true,
        "parameter_details": "Value is within normal range",
        "panel": "Hematology",
        "loinc_code": "718-7",
        "allergen": false
      }
    ]
  }
}
```

### processing\_failed

Sent when processing fails after all retry attempts.

```json
{
  "event": "processing_failed",
  "timestamp": "2024-01-15T10:35:00.000Z",
  "data": {
    "test_id": "clxyz123456",
    "external_id": "test-123",
    "patient_id": "patient-456",
    "status": "failed",
    "error": "Unable to process the uploaded file"
  }
}
```

***

## Webhook Headers

Each webhook request includes these headers:

| Header                 | Description                                                |
| ---------------------- | ---------------------------------------------------------- |
| `X-BloodGPT-Signature` | HMAC-SHA256 signature for verification                     |
| `X-BloodGPT-Event`     | Event type (`processing_completed` or `processing_failed`) |
| `Content-Type`         | Always `application/json`                                  |

***

## Signature Verification

{% hint style="warning" %}
**Security**: Always verify webhook signatures to ensure requests are from BloodGPT.
{% endhint %}

### Signature Format

```
sha256={hex_encoded_hmac}
```

The signature is computed as `HMAC-SHA256(JSON.stringify(payload), secret)` and hex-encoded.

### Verification Examples

{% tabs %}
{% tab title="Node.js" %}

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(signature, payload, secret) {
  if (!signature || !signature.startsWith('sha256=')) {
    return false;
  }

  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Usage in Express
app.post('/webhooks/bloodgpt', (req, res) => {
  const signature = req.headers['x-bloodgpt-signature'];
  const event = req.headers['x-bloodgpt-event'];

  if (!verifyWebhookSignature(signature, req.body, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Process the webhook
  if (event === 'processing_completed') {
    console.log('Report ready:', req.body.data.pdf_url);
  }

  res.status(200).send('OK');
});
```

{% endtab %}

{% tab title="Python" %}

```python
import hmac
import hashlib
import json

def verify_webhook_signature(signature: str, payload: dict, secret: str) -> bool:
    if not signature or not signature.startswith('sha256='):
        return False

    payload_string = json.dumps(payload, separators=(',', ':'))
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        payload_string.encode(),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)

# Usage in Flask
@app.route('/webhooks/bloodgpt', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-BloodGPT-Signature')
    event = request.headers.get('X-BloodGPT-Event')

    if not verify_webhook_signature(signature, request.json, os.environ['WEBHOOK_SECRET']):
        return 'Invalid signature', 401

    if event == 'processing_completed':
        print('Report ready:', request.json['data']['pdf_url'])

    return 'OK', 200
```

{% endtab %}

{% tab title="PHP" %}

```php
function verifyWebhookSignature($signature, $payload, $secret) {
    if (!$signature || strpos($signature, 'sha256=') !== 0) {
        return false;
    }

    $payloadString = json_encode($payload, JSON_UNESCAPED_SLASHES);
    $expectedSignature = 'sha256=' . hash_hmac('sha256', $payloadString, $secret);

    return hash_equals($signature, $expectedSignature);
}

// Usage
$signature = $_SERVER['HTTP_X_BLOODGPT_SIGNATURE'] ?? '';
$payload = json_decode(file_get_contents('php://input'), true);

if (!verifyWebhookSignature($signature, $payload, getenv('WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('Invalid signature');
}

// Process webhook
if ($payload['event'] === 'processing_completed') {
    echo 'Report ready: ' . $payload['data']['pdf_url'];
}
```

{% endtab %}
{% endtabs %}

***

## Retry Policy

BloodGPT automatically retries failed webhook deliveries:

| Attempt | Delay      |
| ------- | ---------- |
| 1       | Immediate  |
| 2       | 30 seconds |
| 3       | 5 minutes  |

Webhooks are considered failed if:

* HTTP status code is not 2xx
* Connection timeout (30 seconds)
* DNS resolution fails

{% hint style="info" %}
After 3 failed attempts, the webhook is marked as failed. You can still retrieve results via polling.
{% endhint %}

***

## Best Practices

<details>

<summary><strong>Respond quickly (&#x3C; 5 seconds)</strong></summary>

Return `200 OK` immediately, then process the webhook asynchronously. Long response times may trigger retries.

```javascript
app.post('/webhooks/bloodgpt', async (req, res) => {
  // Respond immediately
  res.status(200).send('OK');

  // Process asynchronously
  processWebhook(req.body).catch(console.error);
});
```

</details>

<details>

<summary><strong>Handle duplicates idempotently</strong></summary>

Webhooks may be delivered multiple times. Use `test_id` as an idempotency key:

```javascript
const processedTests = new Set();

function handleWebhook(payload) {
  if (processedTests.has(payload.data.test_id)) {
    return; // Already processed
  }
  processedTests.add(payload.data.test_id);
  // Process...
}
```

</details>

<details>

<summary><strong>Use HTTPS endpoints</strong></summary>

Webhook URLs must use HTTPS in production. HTTP is only allowed for local development (`localhost`, `127.0.0.1`).

</details>

***

## Webhook Secret

To obtain a webhook secret for signature verification, contact us at <contact@bloodgpt.com>.
