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

# Law Change Alerts

> Subscribe to a jurisdiction or corpus and get a webhook or email the moment it changes, down to the exact section

Corpus data updates on its own refresh cadence (daily for the Federal Register and the eCFR, weekly for the US Code and agency guidance, monthly for state codes, regulations, and court rules). Law Change Alerts let you subscribe to a **board** (a tracked source, identified by `corpusType` + `state`) and get notified automatically whenever it refreshes with real changes, instead of polling on your own schedule.

Every alert names the exact sections that changed, not just a count, and you can fetch the old text next to the new text for any section that was amended.

Every path below is prefixed with `/api/v1` and every request needs an `Authorization: Bearer vq_key_...` header. All endpoints in this guide are **free**: authenticated and rate-limited, but not credit-metered.

## Boards and watches

A **board** is a source you can watch: `GET /boards` lists every one, each with a `corpusType`, an optional `state` (omitted for federal boards), a human `label`, and its refresh `cadence`. Watching a board does not change how often it refreshes.

A **watch** is your subscription to one board, on one or more channels (webhook, email, or both). By default a watch covers the whole board; see [Narrow a watch to a citation](#narrow-a-watch-to-a-citation) to follow one title or part instead.

Two fields on each board tell you whether narrowing is available. **`scopable`** is `true` when `POST /watches` accepts a `scope` for that source. It is `false` for sources whose documents are not citations, such as the Federal Register, where an item is a numbered document rather than a section of a code, so there is no title or part to narrow by. **`scopeBrowseCorpus`** is the `corpusType` to pass to [`GET /statutes/divisions`](/docs/api-reference) if you want to enumerate a source's titles, chapters and parts. Note that it is deliberately a different vocabulary from the board's own `corpusType`: a board is `cfr`, and `/statutes/divisions` wants `CFR`. When `scopable` is `true` and `scopeBrowseCorpus` is `null`, narrowing works but there is no endpoint that lists that source's hierarchy for you.

<CodeGroup>
  ```bash cURL theme={"theme":"github-dark"}
  curl https://api.vaquill.ai/api/v1/boards \
    -H "Authorization: Bearer vq_key_..."
  ```

  ```python Python theme={"theme":"github-dark"}
  import requests

  boards = requests.get(
      "https://api.vaquill.ai/api/v1/boards",
      headers={"Authorization": "Bearer vq_key_..."},
  ).json()["data"]["boards"]

  for b in boards:
      print(b["corpusType"], b.get("state"), b["label"], b["cadence"])
  ```
</CodeGroup>

```json theme={"theme":"github-dark"}
{
  "data": {
    "boards": [
      {"corpusType": "state", "state": "wa", "label": "State statutes WA", "cadence": "monthly",
       "scopable": true, "scopeBrowseCorpus": "STATE"},
      {"corpusType": "federal_register", "label": "Federal Register", "cadence": "daily",
       "scopable": false, "scopeBrowseCorpus": null}
    ]
  },
  "meta": { "processingTimeMs": 12.4, "creditsConsumed": 0 }
}
```

## Create a watch

`POST /watches` with the board's `corpusType` (and `state`, for a state board) plus a `channel`.

<CodeGroup>
  ```bash cURL theme={"theme":"github-dark"}
  curl -X POST https://api.vaquill.ai/api/v1/watches \
    -H "Authorization: Bearer vq_key_..." \
    -H "Content-Type: application/json" \
    -d '{
      "corpusType": "state",
      "state": "wa",
      "channel": "webhook",
      "webhookUrl": "https://example.com/hooks/vaquill",
      "webhookSecret": "a-secret-only-you-know"
    }'
  ```

  ```python Python theme={"theme":"github-dark"}
  import requests

  resp = requests.post(
      "https://api.vaquill.ai/api/v1/watches",
      headers={
          "Authorization": "Bearer vq_key_...",
          "Content-Type": "application/json",
      },
      json={
          "corpusType": "state",
          "state": "wa",
          "channel": "webhook",
          "webhookUrl": "https://example.com/hooks/vaquill",
          "webhookSecret": "a-secret-only-you-know",
      },
  )
  watch = resp.json()["data"]
  ```
</CodeGroup>

* **`channel`**: `webhook`, `email`, or `both`. `webhookUrl` is required for `webhook`/`both`; `emailAddress` is required for `email`/`both`.
* **`webhookSecret`** (optional): stored encrypted, never returned. When set, every delivery is HMAC-signed (see below).
* **`webhookAuth`** (optional): a credential we send on every delivery, so your gateway can authenticate us. See [Authenticating our deliveries](#authenticating-our-deliveries).
* `webhookUrl` is validated against an SSRF blocklist (loopback, private, and link-local ranges, plus cloud metadata endpoints) at creation and again on every dispatch.
* `404` if the `corpusType`/`state` pair does not match a real, enabled board. `409` if you already have a watch on that board, for that channel, with that same `scope`. Two different scopes on the same board and channel are two separate watches, which is how you follow several parts at once.

`GET /watches` lists your own watches. `PATCH /watches/{watchId}` pauses (`isActive: false`), resumes, or rotates a destination without losing history. `DELETE /watches/{watchId}` removes it.

## Narrow a watch to a citation

Watching an entire source can be more than you want. The Federal regulations refresh daily and change constantly, and a watch on all of `cfr` will tell you about every one of them. If you only care about 21 CFR part 314, say so:

<CodeGroup>
  ```bash cURL theme={"theme":"github-dark"}
  curl -X POST https://api.vaquill.ai/api/v1/watches \
    -H "Authorization: Bearer vq_key_..." \
    -H "Content-Type: application/json" \
    -d '{
      "corpusType": "cfr",
      "channel": "webhook",
      "webhookUrl": "https://example.com/hooks/vaquill",
      "scope": {"title": "21", "part": "314"}
    }'
  ```

  ```python Python theme={"theme":"github-dark"}
  resp = requests.post(
      "https://api.vaquill.ai/api/v1/watches",
      headers={
          "Authorization": "Bearer vq_key_...",
          "Content-Type": "application/json",
      },
      json={
          "corpusType": "cfr",
          "channel": "webhook",
          "webhookUrl": "https://example.com/hooks/vaquill",
          "scope": {"title": "21", "part": "314"},
      },
  )
  ```
</CodeGroup>

`scope` takes four optional keys, from broadest to narrowest: **`title`**, **`chapter`**, **`part`**, **`section`**. Omit `scope` entirely to watch the whole source, which is the default and what every watch created before this feature does.

Two rules keep a scope from matching more than you meant. **`title` is required** whenever you set any narrower level, because a bare part number appears in many unrelated titles. **`section` also needs `chapter` or `part`.** Both are rejected with a `400` naming the problem.

Values may be sent as strings or numbers, and are stored lowercased. A section written with a dot and one written with an underscore are the same section, so `"221.2"` and `"221_2"` both match. The watch echoes back exactly what we stored, so read `scope` from the response rather than assuming your input was kept verbatim.

<Note>
  A scoped watch is silent when nothing inside its scope changed. It does not send an empty "the source was updated" notification on every refresh that touched some other part of the corpus, because that is the noise you narrowed the watch to avoid. Silence means nothing in your scope moved.
</Note>

Everything else follows the scope too. Deliveries carry a `scope` field so a receiver can tell which of its watches fired, `changes` contains only what is inside it, and `GET /watches/{watchId}/changes` applies the same filter, so the read-back never disagrees with what we sent you. Email subjects name the scope, so `eCFR (title 21, part 314) was updated` rather than the same line for every alert you hold on that source.

Sending a `scope` to a source where `scopable` is `false` returns a `400` listing the sources that do accept one.

## Authenticating our deliveries

There are two independent mechanisms, and choosing between them starts with noticing they answer different questions.

|            | `webhookSecret`                       | `webhookAuth`                         |
| ---------- | ------------------------------------- | ------------------------------------- |
| Answers    | Is this body intact and from Vaquill? | Is this caller allowed in?            |
| Sends      | `X-Vaquill-Signature: sha256=<hex>`   | `Authorization`, or a header you name |
| Checked by | Your handler, recomputing an HMAC     | Your gateway, before your code runs   |

Most teams reach for `webhookAuth` first, because it works with middleware they already have. Set neither, either, or both.

| `scheme` | What arrives at your endpoint                                     |
| -------- | ----------------------------------------------------------------- |
| `bearer` | `Authorization: Bearer <secret>`                                  |
| `basic`  | `Authorization: Basic <secret>` (encode `user:password` yourself) |
| `header` | `<headerName>: <secret>`, e.g. `X-Api-Key: <secret>`              |
| `none`   | No credential header. Sending this removes a stored one.          |

```bash cURL theme={"theme":"github-dark"}
curl -X POST https://api.vaquill.ai/api/v1/watches \
  -H "Authorization: Bearer vq_key_..." \
  -H "Content-Type: application/json" \
  -d '{
    "corpusType": "state",
    "state": "wa",
    "channel": "webhook",
    "webhookUrl": "https://example.com/hooks/vaquill",
    "webhookAuth": {"scheme": "header", "headerName": "X-Api-Key", "secret": "k_live_..."}
  }'
```

<Note>
  The credential is stored encrypted and never returned. `GET /watches` shows only `{"scheme": "...", "headerName": "...", "hasSecret": true}`, so you can confirm what is configured without us handing the secret back.
</Note>

**Rotating and removing.** `webhookAuth` is sent as a whole object rather than field by field, because a scheme without a credential is a broken config, not a partial edit.

* Change the credential: same `scheme`, new `secret`.
* Rename the header, keep the token: same `scheme`, new `headerName`, and **omit `secret`**.
* Change scheme: send `secret` again. We will not reuse a bearer token as a raw header value, because that is a guess about your intent that would fail at your endpoint with nothing explaining why.
* Turn it off: `{"scheme": "none"}`, which also deletes the stored credential.

`headerName` cannot be `Authorization` (use `bearer` or `basic`), a transport header, or one of ours (`X-Vaquill-*`). Those are rejected at registration with a `400` rather than failing silently at delivery time.

## Receiving a webhook delivery

A delivery is a `POST` to your `webhookUrl`:

```
Headers:
  Content-Type: application/json
  X-Vaquill-Event: board.updated
  X-Vaquill-Signature: sha256=<hex>   (only when webhookSecret is set)

Body:
{
  "event": "board.updated",
  "corpusType": "state",
  "state": "wa",
  "boardLabel": "Washington statutes",
  "rowsAdded": 4,
  "rowsUpdated": 2,
  "rowsDeleted": 0,
  "refreshLogId": 4412,
  "changes": [
    {"changeKind": "amended", "actId": "wa-regs-296-17-31018", "citation": "WAC 296-17-31018", "title": "Retrospective rating"}
  ],
  "changesOverflowCount": 0,
  "deliveryId": "9f2b1e0a-...:4412",
  "firedAt": "2026-08-07T04:10:00Z"
}
```

<Note>
  `changes` is capped at 10 items; `changesOverflowCount` is the true count beyond that. `changeKind` is `added`, `amended`, or `removed`. `citation`/`title` are null for corpora that don't carry them, so fall back to `actId`.
</Note>

<Tip>
  `deliveryId` is stable per watch per refresh event, even across our own internal retries, so you can dedup safely on your end.
</Tip>

### Verifying the signature

`X-Vaquill-Signature` is a real HMAC, not a token echoed back. It is present only when you set `webhookSecret`; if you want a static credential your gateway can check instead, that is [`webhookAuth`](#authenticating-our-deliveries).

Compute `HMAC-SHA256(webhookSecret, raw_request_body_bytes)` and compare it, constant-time, to the hex digest after `sha256=`. Compute it over the **raw bytes you received**, before parsing JSON: re-serializing the parsed object changes key order and whitespace, and the digest will not match.

```python Python theme={"theme":"github-dark"}
import hashlib
import hmac

def verify_signature(secret: str, raw_body: bytes, header_value: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    got = header_value.removeprefix("sha256=")
    return hmac.compare_digest(expected, got)
```

An `email`/`both` channel watch gets an email instead (or in addition), naming the same changed sections.

## Test before you rely on it

`POST /watches/{watchId}/test` fires a one-off delivery, sent with `X-Vaquill-Event: board.test` so your handler can tell it apart from a real update. Not persisted to delivery history, and does not affect the watch's real last-notified state. Limited to one test per watch every 30 seconds.

```bash cURL theme={"theme":"github-dark"}
curl -X POST https://api.vaquill.ai/api/v1/watches/{watchId}/test \
  -H "Authorization: Bearer vq_key_..."
```

`GET /watches/{watchId}/deliveries` returns webhook delivery history (attempt number, status code, success, error), newest first. Email-only watches always return an empty list here.

## What changed, and the diff

`GET /watches/{watchId}/changes` lists exactly which sections a board added, amended, or removed, newest first, scoped to the watch's own lifetime (a change from before you subscribed never appears, matching your delivery history).

<CodeGroup>
  ```bash cURL theme={"theme":"github-dark"}
  curl "https://api.vaquill.ai/api/v1/watches/{watchId}/changes?limit=50" \
    -H "Authorization: Bearer vq_key_..."
  ```

  ```python Python theme={"theme":"github-dark"}
  changes = requests.get(
      f"https://api.vaquill.ai/api/v1/watches/{watch_id}/changes",
      headers={"Authorization": "Bearer vq_key_..."},
      params={"limit": 50},
  ).json()["data"]["changes"]
  ```
</CodeGroup>

```json theme={"theme":"github-dark"}
{
  "data": {
    "changes": [
      {
        "id": 91,
        "refreshLogId": 4412,
        "corpusType": "state_regulation",
        "state": "wa",
        "changeKind": "amended",
        "actId": "wa-regs-296-17-31018",
        "citation": "WAC 296-17-31018",
        "title": "Retrospective rating",
        "detectedAt": "2026-08-07T04:10:00Z",
        "hasDiff": true
      }
    ]
  },
  "meta": { "processingTimeMs": 12.0, "creditsConsumed": 0, "cursor": 91, "hasMore": false }
}
```

### Polling

You can poll this endpoint as often as you like, including alongside a webhook or email watch.

<Note>
  **There is no "last checked" state to interfere with.** Notifications fire when a corpus refresh completes, not by comparing against a stored watermark, and this endpoint writes nothing. Polling cannot suppress, advance, or double-fire a delivery. (`lastNotifiedAt` on a watch records the outcome of the last delivery attempt, for your visibility. It is never an input to what gets sent.)
</Note>

To fetch only what is new, pass `sinceId` with the `meta.cursor` from your previous page. Ids are a monotonic sequence, so this is exact: it cannot skip a change or return one twice, and it is immune to clock skew.

<CodeGroup>
  ```bash cURL theme={"theme":"github-dark"}
  curl "https://api.vaquill.ai/api/v1/watches/{watchId}/changes?sinceId=91&order=asc&limit=200" \
    -H "Authorization: Bearer vq_key_..."
  ```

  ```python Python theme={"theme":"github-dark"}
  cursor = load_cursor()  # highest id you have processed, or None

  while True:
      page = requests.get(
          f"https://api.vaquill.ai/api/v1/watches/{watch_id}/changes",
          headers={"Authorization": "Bearer vq_key_..."},
          params={"sinceId": cursor, "order": "asc", "limit": 200},
      ).json()

      for change in page["data"]["changes"]:
          handle(change)

      # Null on an empty page, so an idle poll keeps the cursor you already hold.
      if page["meta"]["cursor"] is not None:
          cursor = page["meta"]["cursor"]
          save_cursor(cursor)

      if not page["meta"]["hasMore"]:
          break
  ```
</CodeGroup>

| Parameter    | Notes                                                                                                                                                                                       |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sinceId`    | Return changes with an `id` greater than this. The cursor to build on.                                                                                                                      |
| `since`      | ISO-8601 timestamp, strictly after. Coarser (one refresh writes many rows at the same instant), but useful if all you kept was a `detectedAt` from a webhook body. Combines with `sinceId`. |
| `changeKind` | `added`, `amended`, or `removed`. Repeat for several: `?changeKind=added&changeKind=amended`.                                                                                               |
| `order`      | `desc` (default) for a recent-activity view; `asc` when catching up from a cursor.                                                                                                          |
| `limit`      | 1 to 200, default 50.                                                                                                                                                                       |

`meta.hasMore` is true when the page filled to `limit`, so keep calling with the new cursor until it is false. Use `order=asc` while catching up: walking forward means a page that hits `limit` leaves the gap at a known end.

<Tip>
  Running both channels is a reasonable design: take the webhook as the low-latency path, and poll on a slow timer as a backstop for deliveries your endpoint missed while it was down. Dedup on the change `id`, or on `deliveryId` for whole webhook payloads.
</Tip>

When `hasDiff` is `true`, call `GET /watches/{watchId}/changes/{changeId}/diff` for the section's text before and after that specific change:

<CodeGroup>
  ```bash cURL theme={"theme":"github-dark"}
  curl https://api.vaquill.ai/api/v1/watches/{watchId}/changes/91/diff \
    -H "Authorization: Bearer vq_key_..."
  ```

  ```python Python theme={"theme":"github-dark"}
  diff = requests.get(
      f"https://api.vaquill.ai/api/v1/watches/{watch_id}/changes/91/diff",
      headers={"Authorization": "Bearer vq_key_..."},
  ).json()["data"]

  if diff["hasBefore"] and diff["hasAfter"]:
      print(diff["beforeText"])
      print(diff["afterText"])
  ```
</CodeGroup>

```json theme={"theme":"github-dark"}
{
  "data": {
    "changeId": 91,
    "changeKind": "amended",
    "beforeText": "An application for approval of a new drug...",
    "afterText": "An application for approval of a new drug or amended new drug...",
    "hasBefore": true,
    "hasAfter": true
  },
  "meta": { "processingTimeMs": 41.0, "creditsConsumed": 0 }
}
```

<Note>
  `hasDiff` (on the change) and `hasBefore`/`hasAfter` (on the diff) can legitimately be `false`: an `added` change has nothing to diff against, and a handful of sources track only *that* a section changed, not the text itself (see below). Check the flags rather than assuming both sides are always present.
</Note>

<Warning>
  A small number of sources (`corpusType=cfr`, and any change from before this feature shipped) report real `added`/`amended`/`removed` events but never a diff -- `hasDiff` stays `false` for them by design, not by bug. Everything else, including every state corpus, reports a real diff for every `amended` change.
</Warning>

## Related

<CardGroup cols={2}>
  <Card title="Monitor corpus coverage" icon="chart-line" href="/docs/api-guide/recipes/coverage-monitoring">
    A DIY alternative for detecting when a jurisdiction gains an entirely new corpus, rather than a section-level change within one you already watch.
  </Card>

  <Card title="Rate limits" icon="gauge-high" href="/docs/api-guide/rate-limits">
    Per-plan limits and how to back off on a 429.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/docs/api-guide/errors">
    How error responses are shaped, and what each status code means.
  </Card>

  <Card title="Coverage" icon="table" href="/docs/api-guide/coverage">
    The jurisdiction and corpus matrix you're choosing a board from.
  </Card>
</CardGroup>
