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

# Webhooks

# Webhooks

Receive real-time HTTP notifications when events occur in your Lucitra project. Webhooks eliminate the need to poll for validation status and integrate directly with Slack and Microsoft Teams.

## Events

<AccordionGroup>
  <Accordion title="validation.started">
    Fired when a validation run begins processing.

    ```json theme={null}
    {
      "event": "validation.started",
      "run_id": "run_3fa85f64",
      "dataset_id": "ds_7kx9m2",
      "validation_type": "full",
      "timestamp": "2026-03-06T12:10:00Z"
    }
    ```
  </Accordion>

  <Accordion title="validation.completed">
    Fired when a validation run finishes successfully and a report is available.

    ```json theme={null}
    {
      "event": "validation.completed",
      "run_id": "run_3fa85f64",
      "dataset_id": "ds_7kx9m2",
      "report_id": "rpt_8abc1234",
      "overall_score": 82,
      "timestamp": "2026-03-06T12:15:00Z"
    }
    ```
  </Accordion>

  <Accordion title="validation.failed">
    Fired when a validation run encounters an error.

    ```json theme={null}
    {
      "event": "validation.failed",
      "run_id": "run_3fa85f64",
      "dataset_id": "ds_7kx9m2",
      "error": "Unsupported annotation schema in scene_042",
      "timestamp": "2026-03-06T12:12:00Z"
    }
    ```
  </Accordion>

  <Accordion title="dataset.uploaded">
    Fired when a dataset upload completes and the file is processed.

    ```json theme={null}
    {
      "event": "dataset.uploaded",
      "dataset_id": "ds_7kx9m2",
      "name": "warehouse-v3",
      "format": "coco",
      "scene_count": 5000,
      "timestamp": "2026-03-06T12:05:00Z"
    }
    ```
  </Accordion>

  <Accordion title="report.ready">
    Fired when a report is generated and ready for download (including PDF export).

    ```json theme={null}
    {
      "event": "report.ready",
      "report_id": "rpt_8abc1234",
      "run_id": "run_3fa85f64",
      "overall_score": 82,
      "timestamp": "2026-03-06T12:15:05Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Register a Webhook

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.lucitra.io/v1/webhooks \
    -H "Authorization: Bearer luci_your_api_key" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://example.com/webhooks/lucitra",
      "events": ["validation.completed", "validation.failed"],
      "secret": null,
      "format": null
    }'
  ```

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

  resp = requests.post(
      "https://api.lucitra.io/v1/webhooks",
      headers={"Authorization": "Bearer luci_your_api_key"},
      json={
          "url": "https://example.com/webhooks/lucitra",
          "events": ["validation.completed", "validation.failed"],
          "secret": None,
          "format": None,
      },
  )
  webhook = resp.json()
  # Save webhook["secret"] — it is only shown once
  ```
</CodeGroup>

<ParamField body="url" type="string" required>
  The HTTPS endpoint that will receive webhook deliveries.
</ParamField>

<ParamField body="events" type="array" required>
  List of event types to subscribe to. See the events section above for all options.
</ParamField>

<ParamField body="secret" type="string">
  HMAC signing secret. If omitted, Lucitra auto-generates one prefixed with `whsec_`. The secret is only returned in the creation response.
</ParamField>

<ParamField body="format" type="string">
  Message format. Set to `"slack"` for Slack-compatible payloads, `"teams"` for Microsoft Teams Adaptive Cards, or `null` for raw JSON.
</ParamField>

<Accordion title="Response">
  ```json theme={null}
  {
    "id": "wh_9def5678",
    "secret": "whsec_abc123def456"
  }
  ```

  <ResponseField name="id" type="string" required>
    Webhook identifier for management operations.
  </ResponseField>

  <ResponseField name="secret" type="string" required>
    HMAC-SHA256 signing secret. Shown only once at creation time. Store it securely.
  </ResponseField>
</Accordion>

<Warning>
  The `secret` value is only returned when you create the webhook. Store it immediately in a secure location like a secrets manager. There is no way to retrieve it later.
</Warning>

## Format Options

<Tabs>
  <Tab title="Raw JSON (default)">
    Standard JSON payload sent to your endpoint. Parse and handle it in your application code.

    ```json theme={null}
    {
      "event": "validation.completed",
      "run_id": "run_3fa85f64",
      "report_id": "rpt_8abc1234",
      "overall_score": 82,
      "timestamp": "2026-03-06T12:15:00Z"
    }
    ```
  </Tab>

  <Tab title="Slack">
    Set `format: "slack"` to receive payloads compatible with Slack incoming webhooks. Point the webhook URL at your Slack webhook endpoint.

    ```json theme={null}
    {
      "text": "Validation completed for dataset warehouse-v3",
      "blocks": [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "*Validation Complete* :white_check_mark:\n*Dataset:* warehouse-v3\n*Overall Score:* 82/100\n*Report:* <https://validate.lucitra.ai/reports/rpt_8abc1234|View Report>"
          }
        }
      ]
    }
    ```
  </Tab>

  <Tab title="Microsoft Teams">
    Set `format: "teams"` to receive Adaptive Card payloads compatible with Microsoft Teams incoming webhooks.

    ```json theme={null}
    {
      "type": "message",
      "attachments": [{
        "contentType": "application/vnd.microsoft.card.adaptive",
        "content": {
          "type": "AdaptiveCard",
          "body": [{
            "type": "TextBlock",
            "text": "Validation Complete - Score: 82/100",
            "weight": "Bolder"
          }],
          "actions": [{
            "type": "Action.OpenUrl",
            "title": "View Report",
            "url": "https://validate.lucitra.ai/reports/rpt_8abc1234"
          }]
        }
      }]
    }
    ```
  </Tab>
</Tabs>

## List Webhooks

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.lucitra.io/v1/webhooks" \
    -H "Authorization: Bearer luci_your_api_key"
  ```

  ```python Python theme={null}
  resp = requests.get(
      "https://api.lucitra.io/v1/webhooks",
      headers={"Authorization": "Bearer luci_your_api_key"},
  )
  webhooks = resp.json()
  ```
</CodeGroup>

## Delete a Webhook

<CodeGroup>
  ```bash cURL theme={null}
  curl -X DELETE "https://api.lucitra.io/v1/webhooks/wh_9def5678" \
    -H "Authorization: Bearer luci_your_api_key"
  ```

  ```python Python theme={null}
  resp = requests.delete(
      "https://api.lucitra.io/v1/webhooks/wh_9def5678",
      headers={"Authorization": "Bearer luci_your_api_key"},
  )
  # Returns 204 No Content on success
  ```
</CodeGroup>

## Delivery History

Inspect past delivery attempts for debugging failed webhooks.

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.lucitra.io/v1/webhooks/wh_9def5678/deliveries?limit=20&offset=0" \
    -H "Authorization: Bearer luci_your_api_key"
  ```

  ```python Python theme={null}
  resp = requests.get(
      "https://api.lucitra.io/v1/webhooks/wh_9def5678/deliveries",
      headers={"Authorization": "Bearer luci_your_api_key"},
      params={"limit": 20, "offset": 0},
  )
  deliveries = resp.json()
  ```
</CodeGroup>

<ParamField query="limit" type="integer" default="20">
  Maximum number of delivery records to return.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Number of records to skip for pagination.
</ParamField>

## Test a Webhook

Send a test event to verify your endpoint is reachable and responding correctly.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.lucitra.io/v1/webhooks/wh_9def5678/test" \
    -H "Authorization: Bearer luci_your_api_key"
  ```

  ```python Python theme={null}
  resp = requests.post(
      "https://api.lucitra.io/v1/webhooks/wh_9def5678/test",
      headers={"Authorization": "Bearer luci_your_api_key"},
  )
  result = resp.json()
  ```
</CodeGroup>

<Accordion title="Response">
  ```json theme={null}
  {
    "success": true,
    "http_status": 200,
    "duration_ms": 142,
    "error": null
  }
  ```

  <ResponseField name="success" type="boolean" required>
    Whether the test delivery received a 2xx response.
  </ResponseField>

  <ResponseField name="http_status" type="integer" required>
    HTTP status code returned by your endpoint.
  </ResponseField>

  <ResponseField name="duration_ms" type="integer" required>
    Round-trip time in milliseconds.
  </ResponseField>

  <ResponseField name="error" type="string">
    Error message if the delivery failed. Null on success.
  </ResponseField>
</Accordion>

## Signature Verification

Every webhook delivery includes an `X-Lucitra-Signature` header containing an HMAC-SHA256 signature of the request body. Always verify this signature to ensure the payload was sent by Lucitra and has not been tampered with.

The header format is:

```
X-Lucitra-Signature: sha256=<hex-encoded-hmac>
```

### Verification Example

```python theme={null}
import hmac
import hashlib

def verify_webhook(payload_bytes: bytes, signature_header: str, secret: str) -> bool:
    """Verify that a webhook payload was signed by Lucitra.

    Args:
        payload_bytes: The raw request body as bytes.
        signature_header: The value of the X-Lucitra-Signature header.
        secret: Your webhook signing secret (whsec_...).

    Returns:
        True if the signature is valid.
    """
    if not signature_header.startswith("sha256="):
        return False

    expected_sig = signature_header.removeprefix("sha256=")
    computed_sig = hmac.new(
        secret.encode("utf-8"),
        payload_bytes,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(computed_sig, expected_sig)


# Usage in a Flask handler
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_abc123def456"

@app.route("/webhooks/lucitra", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Lucitra-Signature", "")
    if not verify_webhook(request.data, signature, WEBHOOK_SECRET):
        abort(401, "Invalid signature")

    event = request.get_json()
    print(f"Received event: {event['event']}")
    return "", 200
```

<Warning>
  Always use `hmac.compare_digest` (or equivalent constant-time comparison) instead of `==` to prevent timing attacks against the signature.
</Warning>

<Tip>
  If you rotate your webhook secret, create a new webhook with the new secret and delete the old one. There is no update endpoint for secrets because they are only stored as hashed values.
</Tip>
