> ## 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, every 7 days or monthly for state codes depending on how the state publishes, monthly for state 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. Every endpoint in this guide is **free** except one: [`GET /watches/{id}/changes/{changeId}/diff`](#read-the-diff-for-one-change) is metered, because it reconstructs both sides of the text. Everything else is authenticated and rate-limited but not credit-metered.

<Warning>
  **Two plan limits apply to watches.**

  **Webhook delivery requires the Business plan.** Creating or repointing a watch whose `channel` is `webhook` or `both` returns **403** on any other plan. Email delivery is open to every plan, so `channel: "email"` always works.

  **Watch count is capped at 3** outside Business, and at 100 on Business. Exceeding it returns **429**. Delete a watch before creating another, or upgrade.
</Warning>

## 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. Read `cadence` as the declared staleness window a board is alerted against, not as a ceiling on how often it is re-pulled: nearly every state statute board declares `monthly` (Arkansas, Georgia and Tennessee declare `quarterly`), while the 25 bulk-sourced jurisdictions listed under [Which statute cadence applies where](/docs/api-guide/coverage#which-statute-cadence-applies-where) are in fact re-pulled every 7 days.

Not every jurisdiction we carry is watchable. Seven state statute jurisdictions (Delaware, the District of Columbia, Hawaii, Mississippi, New Mexico, North Carolina and Utah) are covered and queryable but have their automated re-pull held, so they are absent from `GET /boards` rather than listed as idle. A board is switched on only once its refresh has been shown to work. Read `GET /boards` as the list of what can be **watched**, never as the list of what we **cover**; for coverage, use `GET /us/statutes/coverage`.

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 /us/statutes/divisions`](/docs/api-reference/us-statutes/browse-the-statutory-hierarchy) 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 `/us/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?limit=500&offset=0" \
    -H "Authorization: Bearer vq_key_..."
  ```

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

  def all_boards():
      """Page /boards. The default limit is also its maximum, so do not skip this."""
      boards, offset = [], 0
      while True:
          data = requests.get(
              "https://api.vaquill.ai/api/v1/boards",
              headers={"Authorization": "Bearer vq_key_..."},
              params={"limit": 500, "offset": offset},
          ).json()["data"]
          boards.extend(data["boards"])
          if not data.get("hasMore"):
              break
          offset += 500
      return boards

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

<Warning>
  **`GET /boards` truncates silently once the registry passes 500.** `limit` defaults to 500 and 500 is also the maximum, so a call that never passes `offset` returns the first 500 boards and no error. There are about 244 boards today, so the default covers the whole registry right now, but that is a fact about today. Read `data.total` and `data.hasMore` and page on `offset`, as above.

  `GET /boards` is free, so paging it costs nothing but a request against your rate limit.
</Warning>

Filter instead of paging where you can: `corpusType` and `state` both narrow the list server-side. Pass `state=federal` for the boards that have no state (USC, eCFR, the Federal Register), which is not the same as omitting `state`.

```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}
    ],
    "total": 244,
    "hasMore": false
  },
  "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 hierarchy `scope` to a source where `scopable` is `false` returns a `400` listing the sources that do accept one.

### Or watch one exact section

A hierarchy scope cannot express two things: a single section unambiguously, and any narrowing at all of a source whose act\_ids carry no title. Missouri's look like `STATE_MO_C407_S407.822` (chapter and section, no title) because Missouri organizes by chapter, and a hierarchy scope requires `title`.

So a scope may instead name one section outright, using the `actId` that search results and change events already return:

```json theme={"theme":"github-dark"}
{ "scope": { "actId": "CFR_T21_P314_S314_50" } }
```

* **Mutually exclusive with the hierarchy form.** An act\_id already encodes title, part and section, so sending both is a `400` rather than a scope that contradicts itself.
* **Case-sensitive**, and copied verbatim: act\_ids are mixed case (`STATE_LA_Crevised-statutes_T14_S112.11`).
* **Validated when you create the watch.** An act\_id we do not hold, or one belonging to a different source, is a `400`. That is deliberate: an unchecked typo would be a watch that can never fire, and silence from a scoped watch is indistinguishable from a quiet source.
* **Works on every source**, including the flat ones where `scopable` is `false`. It is the only way to follow a single Federal Register document or one Justice Manual chapter.

<Tip>
  If a section is renumbered its act\_id changes, and the watch stops matching. You are not left guessing: renumbering emits a `removed` event carrying the OLD act\_id, so the watch fires once on the way out.
</Tip>

## 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": "CFR_T33_P100_S100_901", "citation": "33 C.F.R. § 100.901 (2026)", "title": "Special local regulations"}
  ],
  "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": "CFR_T33_P100_S100_901",
        "citation": "33 C.F.R. § 100.901 (2026)",
        "title": "Special local regulations",
        "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, for walking forward. |
| `beforeId` | Return changes with an `id` **less** than this. How you page backwards into history. |
| `since` | ISO-8601 timestamp, strictly after. Combines with `sinceId`. See below. |
| `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. |

`since` is coarser than `sinceId`, because one refresh writes many rows at the same instant, but it is what you want if all you kept was a `detectedAt` from a webhook body.

`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.

### Paging backwards with beforeId

`sinceId` only walks forward, so it cannot get you the history that already existed when you created the watch. `beforeId` is how you backfill.

Keep `order=desc` (the default), and pass the **lowest** `id` you have seen as `beforeId` to fetch the page behind it. Repeat until a page comes back empty.

```python Python theme={"theme":"github-dark"}
before = None
history = []

while True:
    params = {"limit": 200, "order": "desc"}
    if before is not None:
        params["beforeId"] = before

    page = requests.get(
        f"https://api.vaquill.ai/api/v1/watches/{watch_id}/changes",
        headers={"Authorization": "Bearer vq_key_..."},
        params=params,
    ).json()

    changes = page["data"]["changes"]
    if not changes:
        break
    history.extend(changes)

    # meta.cursor is the HIGHEST id on the page, so it is the wrong end for
    # walking backwards. Take the lowest id yourself.
    before = min(c["id"] for c in changes)

    if not page["meta"]["hasMore"]:
        break
```

<Note>
  `meta.cursor` is always the **highest** `id` on the page, which is what you want for `sinceId` and not for `beforeId`. Compute the lowest id from the page yourself when walking backwards.
</Note>

<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>

<Warning>
  A change can be **retracted**. When we find that a recorded change was not a change in the law (for example a refresh that mislabelled content it was storing for the first time), we remove it from the record: its `id` stops appearing in `GET /watches/{id}/changes`, and its diff returns `404`. Ids are never reused.
  If you keep a local copy, treat an `id` you hold that no longer appears as withdrawn, rather than as an error on your side.
</Warning>

When `hasDiff` is `true`, call `GET /watches/{watchId}/changes/{changeId}/diff` for the section's text before and after that specific change. **This is the one metered call in this guide, and it is charged whether or not both sides are available**, so branch on `hasDiff` before spending on it.

<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": 4 }
}
```

<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" 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="Errors" href="/docs/api-guide/errors">
    How error responses are shaped, and what each status code means.
  </Card>

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.