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

# Webhooks Overview

> Receive signed HTTP POSTs whenever a claim's collection stage or case status changes.

incaseof.law pushes events to a partner-controlled HTTPS endpoint whenever a claim's lifecycle moves to a new stage. This lets you keep your local mirror of claim status in sync **without polling**.

There are two event types:

* [`claim.collection_stage_changed`](/webhooks/claim-collection-stage-changed) — the claim moved to a new collection stage;
* [`claim.status_changed`](/webhooks/claim-status-changed) — the case status changed (`open`, `disputed`, `closed`), a closure with its reason (`paid`, `installment_plan`, `written_off`, `withdrawn`, `disputed`).

Your endpoint receives every type — there is no per-type subscription. New types may be added by extending the `type` enum (the envelope shape stays stable): acknowledge an event whose `type` you do not know with a 2xx and ignore it.

```mermaid theme={null}
flowchart LR
    A[Stage transition in incaseof.law] --> B[Sign payload with your secret]
    B --> C[POST to your HTTPS endpoint]
    C -->|2xx within 10s| D[Delivered ✓]
    C -->|non-2xx / timeout| E[Retry with backoff]
    E -->|after 6 attempts| F[Marked failed — not re-sent]
```

***

## Setup

Webhooks are configured by an incaseof.law administrator on your behalf:

1. The admin opens **Settings → Partners → Webhooks** and selects your organization.
2. They paste your HTTPS endpoint URL and click **Create webhook**.
3. incaseof.law generates a **signing secret** and displays it **once**. The admin forwards it to you over a secure channel.
4. Store the secret in your config — you'll use it to verify every incoming request (see [Signature verification](#signature-verification) below).

<Note>
  The endpoint URL **must use HTTPS**. Plain HTTP is rejected at config time.
</Note>

***

## Request format

Every delivery is a `POST` to your configured URL with a JSON body.

### Headers

| Header               | Example                          | Notes                                                                        |
| -------------------- | -------------------------------- | ---------------------------------------------------------------------------- |
| `Content-Type`       | `application/json`               | Always JSON.                                                                 |
| `User-Agent`         | `incaseof.law-webhooks/1.0`      |                                                                              |
| `X-IcoLaw-Event`     | `claim.collection_stage_changed` | Mirrors the `type` field in the body.                                        |
| `X-IcoLaw-Delivery`  | `8a7f12bb-…` (UUID)              | **Stable across retries** of one event. Not the same value as the body `id`. |
| `X-IcoLaw-Signature` | `t=1714385662,v1=5257a869e7ec…`  | See below.                                                                   |

### Envelope

Every event uses the same envelope. The `data` field is shaped by `type`.

```json theme={null}
{
  "id": "evt_01HXYZ8K9MGV9C3K3F2K8AB12C",
  "type": "claim.collection_stage_changed",
  "created_at": "2026-05-14T11:30:00.000Z",
  "api_version": "2026-05-14",
  "data": { /* event-specific payload */ }
}
```

| Field         | Type                  | Notes                                                                                                                                               |
| ------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | string                | Stable event ID (`evt_…`), the same on every retry. A different value from the `X-IcoLaw-Delivery` header — dedupe on one of the two, consistently. |
| `type`        | string                | Event type. Drives the shape of `data`.                                                                                                             |
| `created_at`  | RFC 3339 datetime     | When we emitted the event.                                                                                                                          |
| `api_version` | string (`YYYY-MM-DD`) | Payload contract version. Bumped on non-additive changes — pin this in your handler to be notified before breaking changes land.                    |
| `data`        | object                | Event-specific payload — see the page for each event type.                                                                                          |

***

## Signature verification

Each request includes an `X-IcoLaw-Signature` header of the form:

```
t=<unix-seconds>,v1=<hex-hmac-sha256>
```

Where:

```
v1 = HMAC_SHA256(secret, t + "." + raw_body)
```

The `secret` is the value the admin shared with you at setup time.

### Rules

* **Reject** any request where `|now - t| > 5 minutes` (replay protection).
* After a **secret rotation** only the new secret is valid — there is no overlap window (see [Secret rotation](#secret-rotation)).
* Use a **constant-time** comparison (`crypto.timingSafeEqual` / `hmac.compare_digest`).
* Verify against the **raw request body bytes**, not a re-serialised version — JSON key order matters for the HMAC.

### Reference implementations

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

  function verify(rawBody, header, secret) {
    const parts = Object.fromEntries(
      header.split(',').map((p) => {
        const i = p.indexOf('=')
        return [p.slice(0, i), p.slice(i + 1)]
      })
    )
    const t = Number(parts.t)
    if (!Number.isFinite(t)) return false
    if (Math.abs(Date.now() / 1000 - t) > 300) return false // 5 min tolerance

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${t}.${rawBody}`, 'utf8')
      .digest('hex')

    return crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(parts.v1 || '', 'hex')
    )
  }
  ```

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

  def verify(raw_body: bytes, header: str, secret: str) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      try:
          t = int(parts["t"])
      except (KeyError, ValueError):
          return False
      if abs(time.time() - t) > 300:  # 5 min tolerance
          return False
      expected = hmac.new(
          secret.encode(),
          f"{t}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(expected, parts.get("v1", ""))
  ```

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

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

  func Verify(rawBody []byte, header, secret string) bool {
      parts := map[string]string{}
      for _, p := range strings.Split(header, ",") {
          if i := strings.Index(p, "="); i > 0 {
              parts[p[:i]] = p[i+1:]
          }
      }
      t, err := strconv.ParseInt(parts["t"], 10, 64)
      if err != nil {
          return false
      }
      if abs(time.Now().Unix()-t) > 300 {
          return false
      }
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
      mac.Write(rawBody)
      expected := hex.EncodeToString(mac.Sum(nil))
      provided, err := hex.DecodeString(parts["v1"])
      if err != nil {
          return false
      }
      expectedB, _ := hex.DecodeString(expected)
      return hmac.Equal(expectedB, provided)
  }

  func abs(n int64) int64 { if n < 0 { return -n }; return n }
  ```
</CodeGroup>

***

## Secret rotation

Either party can request a secret rotation through the admin team. When rotated:

1. A **new** signing secret is generated and shown to the admin once; the admin forwards it to you.
2. From that moment **every delivery is signed with the new secret only**. There is no overlap window in which the previous secret still verifies.
3. Deliveries your handler rejects until you have deployed the new secret are retried on the [retry schedule](#response--retry-policy) — the last attempt comes about **7 h 12 min** after the first (30 s + 2 min + 10 min + 1 h + 6 h). Agree a time for the rotation with the admin and deploy the new secret within that time, and nothing is lost.

***

## Response & retry policy

Return any **2xx** status code within **10 seconds** to acknowledge the event. Anything else — non-2xx, timeout, connection error — is treated as a failure and triggers the next retry.

Deliveries are sent by a job that runs every **30 seconds**, so each time below can be up to 30 seconds later.

| Attempt | When                                |
| :-----: | :---------------------------------- |
|    1    | 0–30 seconds after the stage change |
|    2    | 30 seconds after attempt 1 failed   |
|    3    | 2 minutes after attempt 2           |
|    4    | 10 minutes after attempt 3          |
|    5    | 1 hour after attempt 4              |
|    6    | 6 hours after attempt 5             |

After the 6th attempt fails, the delivery is marked **failed** and is visible to the incaseof.law team. It is **not sent again** — failed deliveries are not re-fired. Reconcile once a day through [`GET /openapi/claims`](/api-reference/claims/list-claims) (compare `collection_stage` with your mirror), so a missed event cannot leave your data behind.

### While your webhook is disabled

No events are queued for a disabled webhook: stage changes in that time are **not delivered later**, and deliveries still pending when it was disabled are marked failed. After it is re-enabled, reconcile through the API.

<Warning>
  Your handler must respond within **10 seconds**. If you need to do heavy work, return 2xx immediately and process the event asynchronously (e.g. push the body to a queue and return 202).
</Warning>

***

## Best practices

### Deduplicate

The same `X-IcoLaw-Delivery` UUID (and the same body `id`) will arrive multiple times if any attempt fails and is retried. Persist the IDs you've already processed and short-circuit duplicates — pick one of the two and use it consistently:

```js theme={null}
if (await store.has(req.headers['x-icolaw-delivery'])) {
  return res.status(200).end()  // already processed
}
```

### Reconcile out-of-order events

Webhooks are eventually consistent. A retried older event can arrive after a newer one. Always reconcile by `data.claim_id` + `data.changed_at` rather than assuming arrival order:

```js theme={null}
if (existing.last_stage_changed_at >= event.data.changed_at) return  // stale
```

### Don't trust the payload for sensitive fields

The webhook payload deliberately contains **no debtor PII** (no name, no email, no address, no claim amount). If your downstream logic needs those fields, call [`GET /openapi/claims/{id}`](/api-reference/claims/get-claim) with your API token after receiving the event. This keeps the webhook signal-only and avoids exposing debtor data on whichever network your receiver sits on.

### Verify before you parse

Verify the signature **before** doing anything else with the body — including JSON parsing. Reject with `401 Unauthorized` on signature mismatch.

***

## What's NOT included

| Concern                                   | Reason                                                            |
| ----------------------------------------- | ----------------------------------------------------------------- |
| Debtor name, email, address               | PII boundary — fetch via the API if needed.                       |
| Claim amounts                             | Same — fetch via the API.                                         |
| Granular email / postal / SendGrid events | Out of scope for v1. The webhook signals stage transitions only.  |
| Multiple URLs per partner                 | One webhook per partner in v1. Fan-out is a future schema change. |

***

## Next steps

* See the [Collection Stage Events](/webhooks/claim-collection-stage-changed) reference for the payload shape and example bodies.
* Test the integration end-to-end by asking your admin to use the **"Send test event"** button in the webhooks tab — it fires a synthetic event with a fixed `claim_id` (`00000000-…-000000000000`) so you can easily filter test traffic in your handler.
