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

# Webhooks Overview

> Push-based delivery of terminal run events via signed callbacks.

Use webhooks when you want Nexio to push terminal run updates to your backend instead of waiting for
your next poll. Poll `GET /api/v1/runs/{run_id}` for canonical reconciliation.

## Supported Events

| Event           | Fired when                               |
| --------------- | ---------------------------------------- |
| `run.completed` | A run finishes successfully with results |
| `run.failed`    | A run ends with an error                 |

Both placement and entity analysis runs fire the same events. An entity analysis completion
delivers `run.completed` with the analysis output in `data.run.output` instead of ranked
solutions.

## Security

Every webhook delivery is cryptographically signed. You can also add an optional bearer token for
simpler setups. The two mechanisms serve different purposes:

* **Signing secret** (recommended): Nexio generates an HMAC-SHA256 signing secret (`whsec_...`)
  when you create an endpoint. Every delivery includes an `X-Nexio-Signature` header computed from
  the payload. Your server recomputes the signature and compares: proving the request is from Nexio,
  the payload was not tampered with, and the request is not a replay.

* **Auth token** (optional, simpler): A static bearer token you provide when creating or updating
  an endpoint. Nexio includes it as `Authorization: Bearer <token>` on every delivery. Convenient for
  API gateways but does not verify payload integrity or protect against replay.

For production use, always verify the signing secret.

### Verifying the signing secret

Parse the `X-Nexio-Signature` header to extract the timestamp (`t=`) and one or more signatures
(`v1=`). Recompute the HMAC and compare using constant-time equality.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from 'crypto'

  function verifyWebhook(secret, signatureHeader, rawBody) {
    // Parse "t=<unix>,v1=<hex>,v1=<hex>" header
    const parts = signatureHeader.split(',')
    const timestamp = parts.find(p => p.startsWith('t=')).slice(2)
    const signatures = parts.filter(p => p.startsWith('v1=')).map(p => p.slice(3))

    // Reject stale requests (5 minute tolerance)
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)
    if (age > 300) throw new Error('Timestamp too old')

    // Compute expected signature
    const mac = createHmac('sha256', secret)
    mac.update(timestamp)
    mac.update('.')
    mac.update(rawBody)
    const expected = mac.digest('hex')

    // Compare against each v1 signature (supports secret rotation overlap)
    const match = signatures.some(sig =>
      timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'))
    )
    if (!match) throw new Error('Invalid signature')
  }

  // In your webhook handler:
  const rawBody = req.body // must be the raw string, not parsed JSON
  const signature = req.headers['x-nexio-signature']
  verifyWebhook('whsec_...', signature, rawBody)
  ```

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

  def verify_webhook(secret: str, signature_header: str, raw_body: bytes) -> None:
      # Parse "t=<unix>,v1=<hex>,v1=<hex>" header
      parts = signature_header.split(",")
      timestamp = next(p[2:] for p in parts if p.startswith("t="))
      signatures = [p[3:] for p in parts if p.startswith("v1=")]

      # Reject stale requests (5 minute tolerance)
      if int(time.time()) - int(timestamp) > 300:
          raise ValueError("Timestamp too old")

      # Compute expected signature
      message = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()

      # Compare against each v1 signature (supports secret rotation overlap)
      if not any(hmac.compare_digest(expected, sig) for sig in signatures):
          raise ValueError("Invalid signature")

  # In your webhook handler:
  raw_body = request.get_data()  # raw bytes, not parsed JSON
  signature = request.headers["X-Nexio-Signature"]
  verify_webhook("whsec_...", signature, raw_body)
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"errors"
  	"strconv"
  	"strings"
  	"time"
  )

  func VerifyWebhook(secret, signatureHeader string, rawBody []byte) error {
  	// Parse "t=<unix>,v1=<hex>,v1=<hex>" header
  	parts := strings.Split(signatureHeader, ",")
  	var timestamp string
  	var signatures []string
  	for _, p := range parts {
  		if strings.HasPrefix(p, "t=") {
  			timestamp = p[2:]
  		} else if strings.HasPrefix(p, "v1=") {
  			signatures = append(signatures, p[3:])
  		}
  	}

  	// Reject stale requests (5 minute tolerance)
  	ts, _ := strconv.ParseInt(timestamp, 10, 64)
  	if time.Now().Unix()-ts > 300 {
  		return errors.New("timestamp too old")
  	}

  	// Compute expected signature
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(timestamp))
  	mac.Write([]byte("."))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))

  	// Compare against each v1 signature (supports secret rotation overlap)
  	for _, sig := range signatures {
  		if hmac.Equal([]byte(expected), []byte(sig)) {
  			return nil
  		}
  	}
  	return errors.New("invalid signature")
  }
  ```
</CodeGroup>

<Warning>
  Always verify against the **raw request body** (the exact bytes received), not a re-serialized
  version of the parsed JSON. Re-serialization can change key order or whitespace, breaking the
  signature.
</Warning>

## Delivery Contract

* **At least once** delivery: your consumer must be idempotent
* **Ordering not guaranteed**: don't depend on event order
* **Same `X-Nexio-Delivery` UUID** across retries for the same endpoint delivery
* **Immutable payload**: the JSON snapshot is fixed at delivery creation

### Retry schedule

Failed deliveries retry with exponential backoff: 1 min, 5 min, 30 min, 2 hours, 12 hours.
After all retries are exhausted, the delivery is dead-lettered.

### Delivery headers

| Header                    | Value                                                      |
| ------------------------- | ---------------------------------------------------------- |
| `Content-Type`            | `application/json`                                         |
| `User-Agent`              | `Nexio-Webhooks/1.0`                                       |
| `X-Nexio-Event`           | `run.completed` or `run.failed`                            |
| `X-Nexio-Delivery`        | Stable delivery UUID                                       |
| `X-Nexio-Timestamp`       | Unix timestamp used in signing                             |
| `X-Nexio-Webhook-Version` | Payload version (e.g. `2026-03-22`)                        |
| `X-Nexio-Signature`       | `t=<unix>,v1=<hex>` (multiple `v1=` during secret overlap) |
| `Authorization`           | `Bearer <auth_token>` when configured on the endpoint      |

### Example delivery payload (placement run)

```json theme={null}
{
  "id": "evt_123",
  "type": "run.completed",
  "webhook_version": "2026-03-22",
  "created_at": "2026-03-22T12:00:00Z",
  "data": {
    "run": {
      "run_id": "run_123",
      "status": "completed",
      "environment": "live",
      "created_at": "2026-03-22T11:59:48Z",
      "completed_at": "2026-03-22T12:00:00Z",
      "output": {
        "solutions_count": 2,
        "top_label": "recommended",
        "top_score": 3.75
      },
      "solutions": [
        {
          "id": "0b058b72-dec6-4ef9-915f-546fa6cb9377",
          "rank": 1,
          "cluster_label": "recommended",
          "requirements_met": ["lob_auto", "lob_home", "lob_umbrella"],
          "provider_count": 1,
          "est_cost_low": 11985,
          "est_cost_high": 11985,
          "offerings": [
            { "id": "quote_line_carrier_c_auto_001", "provider_name": "Carrier C", "category": "auto" },
            { "id": "quote_line_carrier_c_home_001", "provider_name": "Carrier C", "category": "home" },
            { "id": "quote_line_carrier_c_umbrella_001", "provider_name": "Carrier C", "category": "umbrella" }
          ],
          "scorecard": { "overall_level": 3.85 }
        }
      ]
    }
  }
}
```

### Example delivery payload (entity analysis run)

```json theme={null}
{
  "id": "evt_456",
  "type": "run.completed",
  "webhook_version": "2026-03-22",
  "created_at": "2026-03-22T12:05:00Z",
  "data": {
    "run": {
      "run_id": "run_456",
      "status": "completed",
      "environment": "live",
      "created_at": "2026-03-22T12:04:48Z",
      "completed_at": "2026-03-22T12:05:00Z",
      "output": {
        "response_type": "COVERAGE_GAP_ANALYSIS",
        "profile_summary": {
          "state": "NJ",
          "coverage_lines_present": ["HOMEOWNERS", "AUTO"],
          "coverage_lines_missing": ["umbrella"]
        },
        "gaps": [
          {
            "id": "gap_missing_umbrella",
            "severity": "HIGH",
            "category": "MISSING_LINE",
            "title": "No umbrella/excess liability policy",
            "recommendation": "Add a $1M-$2M personal umbrella policy."
          }
        ],
        "summary": {
          "total_gaps": 3,
          "high_severity": 1,
          "medium_severity": 2,
          "low_severity": 0,
          "lines_reviewed": 2,
          "lines_recommended": 1,
          "underwriting_flags": 0
        }
      }
    }
  }
}
```

## Best Practices

* **Return `2xx` quickly** and process downstream work asynchronously. Nexio retries on non-2xx
  responses with exponential backoff.
* **Deduplicate on `X-Nexio-Delivery`**: the same delivery UUID is sent across retries.
* **Verify signatures** using the code examples above.
* **Reject stale timestamps** older than 5 minutes to prevent replay attacks.
* **Poll the run status endpoint** as the canonical reconciliation read if you miss a delivery
  or receive events out of order: use `GET /api/v1/runs/{run_id}`.
