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

# Webhooks

> Get pushed a signed notification the moment a submission changes state — instead of polling.

<Note>
  **Early access.** Webhooks are rolling out. The subscription API is **self-serve** — you authenticate with the **same `AccessToken`** you already use for the rest of the API, with no special provisioning or per-partner setup. If the public endpoint isn't live for you yet, or you'd like to be part of the early rollout, reach out to [api-support@coverwhale.com](mailto:api-support@coverwhale.com). Until you've confirmed events are flowing, keep polling as you do today.
</Note>

## Overview

Webhooks let Cover Whale **push** you a notification the instant a submission you care about changes state — a bind completes, a policy enters or exits pending cancellation, a policy is canceled or reinstated. Instead of polling `GET /v1/submission/{displayId}` on a timer, you register an HTTPS endpoint once and receive a signed `POST` when something actually happens.

<Note>
  Webhooks are the recommended integration pattern. Polling still works, but it is slower to react, wastes both sides' capacity, and is rate-limited. If you poll only to detect status changes, move to webhooks.
</Note>

**How it works:**

1. You create a **subscription** — an HTTPS endpoint URL plus the event types you want — via the subscription API. Cover Whale returns a signing **secret** once.
2. When a matching event fires for a submission owned by your account, Cover Whale sends a `POST` to your endpoint with a JSON body signed with your secret.
3. Your endpoint verifies the signature, responds `2xx`, and (if it needs the full record) fetches submission detail on demand.

Every event is **keyed on `display_id`** and carries no documents — you fetch the full submission only when you actually need it, only for the one submission that changed.

## Event types

You subscribe to one or more of these event types:

| Event type                        | Fires when                                                                                                                                                                                                                                                                                                                                          |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `submission.quoted`               | A **new-business or renewal** quote is ready to present. (Endorsement re-quotes are excluded — they fire on every rate recalculation.)                                                                                                                                                                                                              |
| `submission.bind_completed`       | A **new-business** requested bind completes (the policy is now bound).                                                                                                                                                                                                                                                                              |
| `submission.endorsement_bound`    | A mid-term **endorsement** binds (coverage change, vehicle add, etc.) on an existing policy.                                                                                                                                                                                                                                                        |
| `submission.renewed`              | A policy **renews** for a new term (a renewal bind completes).                                                                                                                                                                                                                                                                                      |
| `submission.pending_cancellation` | A bound policy enters pending cancellation. This is your window to intervene before it finalizes.                                                                                                                                                                                                                                                   |
| `submission.canceled`             | A pending cancellation is finalized — the policy is canceled.                                                                                                                                                                                                                                                                                       |
| `submission.reinstated`           | A pending-cancellation or canceled policy returns to bound (either a pending-cancel revoke or a post-cancel reinstatement).                                                                                                                                                                                                                         |
| `submission.public_reply`         | Cover Whale posts a **public, broker-visible reply or message** on your submission (staff replies and automated broker notifications alike — payment confirmations, filing notices, etc.). Internal, staff-only notes never fire it. Its payload differs from the status events — see [Public-reply payload](#the-submission-public-reply-payload). |

<Note>
  `bind_completed` fires only for **new-business** binds; endorsement and renewal binds are their own events (`endorsement_bound`, `renewed`) so a busy policy doesn't flood you with "bind" events. Subscribe to whichever you need.
</Note>

<Tip>
  Use `previous_status` on the `reinstated` event to tell the two paths apart: `5` (was pending cancellation → revoked) vs `6` (was canceled → reinstated).
</Tip>

<Info>
  **`submission.public_reply` is in early access** alongside the status events. It fires on **public, broker-visible messages** — staff replies and automated broker notifications (payment confirmations, filing notices, quote messages). **Internal, staff-only notes never fire it.** Because a reply isn't a status change, the payload is status-less: it carries a `note_id` **reference** instead of `previous_status` / `new_status`, and never embeds the message content — you fetch the reply body on demand (see below). Want it switched on for your account? Reach out to [api-support@coverwhale.com](mailto:api-support@coverwhale.com).

  Expect roughly **1,200–1,500 public replies per day** at weekday peak across an active book — subscribe deliberately.
</Info>

## The event payload

Every delivery has the same envelope. It carries status metadata only — never documents, COIs, policy PDFs, or download URLs.

```json theme={null}
{
  "payload_version": 1,
  "event_type": "submission.bind_completed",
  "event_id": "154b47f1-739b-48b3-b8e8-b2f8dbd4f156",
  "display_id": "240100012345",
  "transaction_id": "1365872",
  "previous_status": 2,
  "new_status": 1,
  "occurred_at": "2026-07-06T20:41:07+00:00"
}
```

| Field             | Type              | Description                                                                                            |
| ----------------- | ----------------- | ------------------------------------------------------------------------------------------------------ |
| `payload_version` | integer           | Contract version. `1` today; we bump it on any breaking change.                                        |
| `event_type`      | string            | One of the event types above.                                                                          |
| `event_id`        | string (UUID)     | Globally unique id for this delivery. Also sent as the `X-CW-Event-Id` header — use it to deduplicate. |
| `display_id`      | string            | The submission/policy id you index on. Fetch full detail with `GET /v1/submission/{display_id}`.       |
| `transaction_id`  | string            | The specific transaction row id. Distinguishes, e.g., a revoke from a reinstatement.                   |
| `previous_status` | integer           | The submission's last partner-visible status before this event (see note below).                       |
| `new_status`      | integer           | The status after this event.                                                                           |
| `occurred_at`     | string (ISO 8601) | When the transition occurred.                                                                          |

**Status codes:** `1` = Bound, `2` = Requested Bind, `5` = Pending Cancellation, `6` = Canceled, `10` = Not Taken Up.

### The `submission.public_reply` payload

`submission.public_reply` uses the same envelope but is **status-less** — no `previous_status` / `new_status`. It carries a `note_id` reference and never embeds the message body:

```json theme={null}
{
  "payload_version": 1,
  "event_type": "submission.public_reply",
  "event_id": "9a1f0c2e-4d7b-4f8a-9c11-6b2e5d0a7f33",
  "display_id": "240100012345",
  "transaction_id": "1365872",
  "note_id": "884213",
  "occurred_at": "2026-07-06T20:41:07+00:00"
}
```

| Field            | Type              | Description                                                          |
| ---------------- | ----------------- | -------------------------------------------------------------------- |
| `event_type`     | string            | Always `submission.public_reply`.                                    |
| `display_id`     | string            | The submission/policy id.                                            |
| `transaction_id` | string            | The submission transaction row id.                                   |
| `note_id`        | string            | Reference to the reply. Fetch the body with the note endpoint below. |
| `occurred_at`    | string (ISO 8601) | When the reply was posted.                                           |

#### Fetch the reply content

The webhook is content-free by design. Retrieve the reply body on demand — only for the one note that fired — with your existing `AccessToken`:

```bash theme={null}
curl https://api.coverwhale.com/v1/submission/{displayId}/notes/{note_id} \
  -H "AccessToken: eyJraWQiOiJnRk5oTTh2RnRKWXVDVXU1S..."
```

```json theme={null}
{
  "note_id": "884213",
  "display_id": "240100012345",
  "body": "Your down payment has been received — coverage is active.",
  "occurred_at": "2026-07-06T20:41:07+00:00"
}
```

<Note>
  Privacy is enforced hard: the endpoint returns the note **only** if it is a public reply on a submission your account owns. Internal notes, notes on another company's submission, and missing ids all return an **identical `404`** — it never reveals whether a non-public note exists.
</Note>

<Note>
  `previous_status` is the last **partner-visible** state, not necessarily the immediately-prior internal status (the transition may pass through internal sub-states you never see). Treat the `event_type` as authoritative for *what happened*, and use `previous_status`/`new_status` to reconcile — not as an exact audit of the prior status.
</Note>

## Register a webhook

Manage your subscriptions with the subscription API. It uses the **same `AccessToken`** you already use for the rest of the Cover Whale API (see [Authentication](/authentication)) — no separate credential.

Base path: `https://api.coverwhale.dev/v1/webhook-subscriptions` (test) · `https://api.coverwhale.com/v1/webhook-subscriptions` (production).

### Create a subscription

```bash theme={null}
curl -X POST https://api.coverwhale.dev/v1/webhook-subscriptions \
  -H "Content-Type: application/json" \
  -H "AccessToken: eyJraWQiOiJnRk5oTTh2RnRKWXVDVXU1S..." \
  -d '{
    "endpoint_url": "https://hooks.your-domain.com/coverwhale/events",
    "event_types": ["submission.bind_completed", "submission.canceled", "submission.reinstated"]
  }'
```

```json theme={null}
{
  "id": 41,
  "company_id": 6042,
  "endpoint_url": "https://hooks.your-domain.com/coverwhale/events",
  "event_types": ["submission.bind_completed", "submission.canceled", "submission.reinstated"],
  "active": true,
  "secret": "3f9a1c...store-this-now...",
  "secret_rotated_at": null,
  "created_at": "2026-07-06T18:00:00+00:00",
  "updated_at": "2026-07-06T18:00:00+00:00"
}
```

<Warning>
  The `secret` is returned **once**, on create and on rotate only. Store it immediately — `GET`/`PUT` responses never include it. If you lose it, call `rotate-secret` to issue a new one.
</Warning>

* `endpoint_url` **must be HTTPS**.
* `event_types` must be a non-empty subset of the event types above.

### Manage subscriptions

| Method   | Path                                        | Purpose                                                                                                                                                              |
| -------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST`   | `/webhook-subscriptions`                    | Create a subscription (returns the `secret` once).                                                                                                                   |
| `GET`    | `/webhook-subscriptions`                    | List your subscriptions.                                                                                                                                             |
| `GET`    | `/webhook-subscriptions/{id}`               | Show one subscription.                                                                                                                                               |
| `PUT`    | `/webhook-subscriptions/{id}`               | Update `endpoint_url`, `event_types`, or `active`.                                                                                                                   |
| `DELETE` | `/webhook-subscriptions/{id}`               | Delete a subscription — stops deliveries and removes it from your list (there is no un-delete). To pause without removing it, set `active: false` via `PUT` instead. |
| `POST`   | `/webhook-subscriptions/{id}/rotate-secret` | Issue a new signing secret (returned once).                                                                                                                          |

All operations are automatically scoped to your account — you can only see and modify your own subscriptions. Prefer `active: false` (via `PUT`) over `DELETE` when you just want to pause deliveries and keep the subscription listed.

## Verify the signature

**Always verify every delivery before trusting it.** Your endpoint is public; the signature is what proves a request actually came from Cover Whale.

Each delivery carries these headers:

| Header                    | Value                                                                                                                                                                                                    |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-CW-Signature`          | `hmac_sha256(raw_body, your_secret)`, hex-encoded.                                                                                                                                                       |
| `X-CW-Signature-Previous` | Present **only during a secret-rotation grace window** — the same HMAC computed with your *previous* secret. Accept the delivery if `X-CW-Signature` **or** this validates.                              |
| `X-CW-Event-Id`           | The payload's `event_id`. Use it to deduplicate retries.                                                                                                                                                 |
| `X-CW-Timestamp`          | Unix timestamp of the send. **Advisory — this header is not covered by the signature**, so don't rely on it alone. Anchor replay/idempotency on the signed `event_id` and `occurred_at` inside the body. |

To verify: compute `HMAC-SHA256` over the **exact raw bytes** of the request body (not a re-serialized copy) using your secret, and compare to `X-CW-Signature` with a **constant-time** comparison.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verify(rawBody, headers, secret) {
    const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
    const sig = headers['x-cw-signature'] || '';
    const prev = headers['x-cw-signature-previous'];
    const ok = (s) =>
      s && s.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected));
    return ok(sig) || ok(prev); // accept either during a rotation window
  }
  ```

  ```php PHP theme={null}
  function verify(string $rawBody, array $headers, string $secret): bool {
      $expected = hash_hmac('sha256', $rawBody, $secret);
      $sig  = $headers['X-CW-Signature'] ?? '';
      $prev = $headers['X-CW-Signature-Previous'] ?? '';
      return hash_equals($expected, $sig) || hash_equals($expected, $prev);
  }
  ```

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

  def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
      expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
      sig  = headers.get("X-CW-Signature", "")
      prev = headers.get("X-CW-Signature-Previous", "")
      return hmac.compare_digest(expected, sig) or hmac.compare_digest(expected, prev)
  ```
</CodeGroup>

### Rotating your secret

Call `POST /webhook-subscriptions/{id}/rotate-secret` to issue a new secret (returned once). For a grace window after rotation, Cover Whale signs every delivery with **both** secrets: `X-CW-Signature` uses the new secret and `X-CW-Signature-Previous` uses the old one. Accept a delivery whose signature matches **either**. This lets you roll your receiver over to the new secret without dropping in-flight events. Once the window elapses, only the new secret is sent.

## Delivery, retries, and idempotency

* **Asynchronous.** Delivery never blocks the underlying bind/cancel/reinstate — an event may arrive a moment after the change.
* **Respond `2xx` within 10 seconds.** Any `2xx` means "received." We wait up to **10 seconds** for your response, so acknowledge first and process asynchronously; don't do slow work before responding, or you'll time out and trigger a retry.
* **Retries with backoff.** On a timeout, connection error, or `5xx`, Cover Whale retries with exponential backoff — up to 8 attempts over roughly 24 hours, with the gap between attempts growing `1m → 5m → 15m → 1h → 3h → 6h → 12h`.
* **`4xx` is permanent.** A `4xx` tells us the request itself is unacceptable — we do **not** retry; the event is dead-lettered immediately. Return `4xx` only when you truly cannot accept the event, not for transient problems.
* **Dead-letter + replay.** After retries are exhausted, an event is dead-lettered. Once your endpoint is healthy again, contact [support](mailto:api-support@coverwhale.com) with the affected `display_id`(s) or time window and we can replay from our ledger.
* **Idempotency.** Retries reuse the same `event_id` (`X-CW-Event-Id`). Deduplicate on it — you may receive the same event more than once, but it is the same logical event.
* **No ordering guarantee.** Under retry, events for the same `display_id` can arrive out of order. Reconcile with `previous_status` / `new_status` / `occurred_at`, not arrival order.

## Best practices

<Steps>
  <Step title="Verify every request">
    Reject any delivery whose signature doesn't validate. An unverified webhook endpoint is an open door.
  </Step>

  <Step title="Acknowledge fast, process later">
    Return `2xx` immediately, enqueue the event, and do the real work off the request path. Slow handlers cause timeouts and unnecessary retries.
  </Step>

  <Step title="Be idempotent">
    Key your processing on `event_id` so a redelivered event is a no-op.
  </Step>

  <Step title="Guard against replay">
    Anchor replay protection on the **signed** `event_id` (dedup) and `occurred_at` (reject events far in the past) — both are inside the signed body. `X-CW-Timestamp` is a convenience header only and is not covered by the signature.
  </Step>

  <Step title="Fetch on demand">
    The payload is intentionally lean. When you need the full record, fetch `GET /v1/submission/{display_id}` — only for the submission that changed.
  </Step>

  <Step title="Monitor and log">
    Log `event_id`, `event_type`, and `display_id` for every delivery so you can reconcile and request replays if you have an outage.
  </Step>
</Steps>

## Troubleshooting

| Symptom                                  | Likely cause / fix                                                                                                                                                                              |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Signature never validates                | You're hashing a re-serialized body. Sign the **raw bytes** exactly as received, before any JSON parse/re-encode.                                                                               |
| Missing events after an outage           | Your endpoint returned errors and events dead-lettered. Contact support with the `display_id`(s) or window for a replay.                                                                        |
| Duplicate events                         | Expected under retry — deduplicate on `X-CW-Event-Id`.                                                                                                                                          |
| `401` creating a subscription            | Your `AccessToken` is missing or expired. Refresh it (see [Authentication](/authentication)).                                                                                                   |
| Not receiving an event type you expected | Confirm it's in your subscription's `event_types`, and note `bind_completed` is new-business only — [contact support](mailto:api-support@coverwhale.com) for endorsement/renewal notifications. |

Need help? Reach us at [api-support@coverwhale.com](mailto:api-support@coverwhale.com).
