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

# Webhooks

> Delivery format, fan-out, signature verification, retries, and the event catalog.

<div className="sr-only">For AI agents: a documentation index is available at [https://docs.coverbase.com/llms.txt](https://docs.coverbase.com/llms.txt) — this page is also available in markdown by appending .md to the URL.</div>

Webhooks are the outbound half of an integration. This page documents how events are delivered, how to verify signatures, retry behavior, and the event catalog. To register, list, update, delete, test, or inspect the delivery history of a subscription, see the [Webhooks API](/api-reference/webhooks). For the audit trail of every attempt, see [Webhook delivery history](/integrations/webhook-deliveries).

## When deliveries happen

When a subscribed event occurs, Coverbase POSTs a JSON envelope to **every active webhook subscribed to that event type**. Deliveries originate from three sources, and all of them are recorded in [delivery history](/integrations/webhook-deliveries):

* **Domain-event fan-out** — a domain event (e.g. a vendor is created) fans out to every subscription for that event type.
* **Workflow `send_webhook` action** — a workflow automation explicitly delivers its triggering event. See [Workflow engine → Actions](/integrations/workflow-engine#actions).
* **Manual test** — the [test endpoint](/api-reference/webhooks#test-a-webhook) sends a one-off delivery to a single subscription.

For one source event, the **same `event_id` is shared across all webhooks** in the fan-out. Receivers can use it to dedupe a fan-out and to correlate a multi-subscription delivery.

## Delivery format

A delivery is an HTTP `POST` with a JSON body in this exact envelope:

```json theme={null}
{
  "event_id": "cbevt_8b3c1a4d9e7f2c5b6a8d1e3f9c7b4a2e",
  "event_type": "Vendor.Created",
  "occurred_at": 1747400000,
  "data": { "event_type": "Vendor.Created", "vendor_id": "cbvndr_e448ba62882143f3ba0c140bb2e30162" }
}
```

| Field         | Type    | Notes                                                                                                                                                                                                     |
| ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_id`    | string  | Shared delivery/event identifier (`cbevt_...`). The same value for every webhook in one fan-out.                                                                                                          |
| `event_type`  | string  | The event type string (see the [event catalog](#event-catalog)).                                                                                                                                          |
| `occurred_at` | integer | Unix timestamp in **seconds**, generated at send time.                                                                                                                                                    |
| `data`        | object  | Event-specific payload. For real events these are the catalog fields for that event type (and `data` echoes `event_type`). For a manual test, `data` is your custom `payload` (default `{"test": true}`). |

The envelope is exactly these four keys — there is no top-level `workflow_run_id` or any other field.

### Request headers

| Header                   | Value                                                                              |
| ------------------------ | ---------------------------------------------------------------------------------- |
| `Content-Type`           | `application/json`                                                                 |
| `Coverbase-Signature`    | Lowercase hex HMAC-SHA256 of the raw request body, keyed by your webhook `secret`. |
| `X-Coverbase-Event-Id`   | Same value as `event_id`.                                                          |
| `X-Coverbase-Event-Type` | Same value as `event_type`.                                                        |

## Signature verification

The signature is `HMAC-SHA256(secret, raw_request_body)` rendered as a lowercase hex string, sent in the `Coverbase-Signature` header. The key is the `secret` you registered with the subscription. Verify it against the **raw bytes** of the request body, before parsing JSON, with a constant-time comparison.

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

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)

# Flask example
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_a1b2c3d4e5f6a7b8c9d0e1f2"  # the secret you registered

@app.post("/coverbase")
def receive():
    raw = request.get_data()  # raw bytes, do not re-serialize
    sig = request.headers.get("Coverbase-Signature", "")
    if not verify(raw, sig, WEBHOOK_SECRET):
        abort(401)
    event = request.get_json()
    # dedupe on request.headers["X-Coverbase-Event-Id"] before handling
    return "", 200
```

```javascript Node.js theme={null}
const crypto = require("crypto");

function verify(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express example (use express.raw so rawBody is the unparsed buffer)
app.post(
  "/coverbase",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.header("Coverbase-Signature") || "";
    if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
      return res.status(401).end();
    }
    const event = JSON.parse(req.body.toString("utf8"));
    // dedupe on req.header("X-Coverbase-Event-Id") before handling
    res.status(200).end();
  },
);
```

## Delivery & retries

| Property             | Value                                                                 |
| -------------------- | --------------------------------------------------------------------- |
| HTTP method          | `POST`                                                                |
| Connection timeout   | 10 seconds per attempt                                                |
| Retries              | Up to 5 attempts on failure (timeout or connection error)             |
| Retry backoff        | None — Hatchet retries immediately; no exponential backoff, no jitter |
| Ordering             | Not guaranteed — deliveries are unordered                             |
| Guarantee            | At-least-once — the same `event_id` may be delivered more than once   |
| Endpoint requirement | Public HTTPS endpoint (see [URL restrictions](#url-restrictions))     |

`occurred_at` and `event_id` are generated at send time. Make your receiver **idempotent**: dedupe on `X-Coverbase-Event-Id` and process the same event id at most once. Respond with a `2xx` quickly to acknowledge; non-`2xx`, timeouts, and connection errors are retried up to 5 times in rapid succession.

### Source IP

Coverbase does **not** publish a fixed egress IP range for outbound webhook deliveries. Authenticate the sender by **HMAC signature verification** (see [Signature verification](#signature-verification)) — not by source IP. The compute fleet is autoscaled and the public IPs that originate deliveries change frequently.

If you operate behind a network perimeter that requires a static allow-list, route deliveries through a customer-controlled HTTPS proxy with a stable egress IP and forward to the real receiver after re-verifying the signature.

## Delivery history retention

Every delivery attempt is recorded in [delivery history](/integrations/webhook-deliveries) for **90 days**. Older rows are deleted by a daily background job; the API still serves the window inside retention via `GET /v1/webhooks/{id}/deliveries`. Capture deliveries you need beyond that window from your receiver side at delivery time.

## URL restrictions

Webhook URLs must be **public HTTPS endpoints**. A URL is rejected at create/update time (`400 invalid_url`) if it is not HTTPS, carries credentials, uses a non-standard port, or resolves to a private, loopback, link-local, or metadata address (e.g. `169.254.169.254`).

The same check is **re-applied with DNS resolution at delivery time**, so a hostname that is repointed to an internal address after registration (DNS rebinding) is still blocked. A delivery blocked this way is recorded with status `error` in [delivery history](/integrations/webhook-deliveries).

## Event catalog

These are the **only** values valid in a subscription's `events` array and the only event types emitted. The `events` field is strictly validated against this set — see the [behavior change](#strict-event-type-validation) below. Use the exact, case-sensitive strings.

| `event_type`                          | `data` fields                                                               |
| ------------------------------------- | --------------------------------------------------------------------------- |
| `Assessment.Created`                  | `assessment_id`                                                             |
| `Assessment.AssigneeChanged`          | `assessment_id`, `old_assignee_id` (nullable), `new_assignee_id` (nullable) |
| `Assessment.StatusChanged`            | `assessment_id`, `old_status`, `new_status`                                 |
| `QuestionnaireResponse.Created`       | `questionnaire_response_id`                                                 |
| `QuestionnaireResponse.StatusChanged` | `questionnaire_response_id`, `old_status`, `new_status`                     |
| `Review.StatusChanged`                | `review_id`, `old_status_id` (nullable), `new_status_id` (nullable)         |
| `WorkQueueItem.Created`               | `work_queue_item_id`                                                        |
| `WorkQueueItem.Completed`             | `work_queue_item_id`                                                        |
| `Vendor.Created`                      | `vendor_id`                                                                 |
| `Vendor.Updated`                      | `vendor_id`                                                                 |
| `Procurement.Created`                 | `procurement_id`                                                            |
| `Procurement.StatusChanged`           | `procurement_id`, `old_status_id` (nullable), `new_status_id`               |
| `Service.Created`                     | `service_id`                                                                |
| `Service.Updated`                     | `service_id`                                                                |
| `OrgIntake.QuestionnaireRemoved`      | `questionnaire_id`, `intake_disabled` (bool)                                |
| `OrgIntake.Disabled`                  | `removed_questionnaire_id`                                                  |
| `IntakeSession.Created`               | `intake_session_id`                                                         |
| `IntakeSession.Submitted`             | `intake_session_id`                                                         |
| `RadarEvent.Created`                  | `radar_event_id`                                                            |
| `RadarEvent.Updated`                  | `radar_event_id`, `field_diffs`                                             |
| `RadarEvent.Deleted`                  | `radar_event_id`                                                            |
| `RadarDetectorResult.Created`         | `radar_detector_result_id`                                                  |
| `RadarDetectorResult.Updated`         | `radar_detector_result_id`, `field_diffs`                                   |
| `RadarDetectorResult.Deleted`         | `radar_detector_result_id`                                                  |
| `Reassessment.Created`                | `reassessment_id`                                                           |
| `Reassessment.Updated`                | `reassessment_id`, `field_diffs`                                            |
| `Reassessment.Deleted`                | `reassessment_id`                                                           |

<Note>
  A **radar alert** is a `RadarDetectorResult` — subscribe to `RadarDetectorResult.Created` to be notified of new alerts and `RadarDetectorResult.Updated` (whose `field_diffs` will include `is_dismissed`) for triage activity. `*.Updated` events carry a `field_diffs` object mapping each changed field to its `{ "old_value", "new_value" }`. A soft-archive (`is_archived` → `true`) surfaces as `*.Deleted`, not `*.Updated`. See the [Radar](/api-reference/radar) and [Reassessments](/api-reference/reassessments) APIs.
</Note>

All IDs are opaque prefixed strings (e.g. `cbvndr_...`, `cbqsrw_...`); see [API conventions → IDs](/conventions#ids). Every event's `data` also echoes its own `event_type`.

<Note>
  `webhook.test` is a valid value only for the [test endpoint's](/api-reference/webhooks#test-a-webhook) `event_type` field. It is **not** a subscribable event type — it will be rejected if used in a subscription's `events` array.
</Note>

### Strict event-type validation

<Warning>
  **Breaking change.** `events` is now strictly validated against the catalog above. Previously any free-form string was accepted. A `POST /v1/webhooks` or `PATCH /v1/webhooks/{id}` whose `events` contains a value not in the catalog now fails with `422 invalid_event_types`. Integrations that registered non-canonical strings (e.g. lowercase `vendor.created`) must switch to the exact catalog value (e.g. `Vendor.Created`).
</Warning>
