---
title: "Create a board watch"
method: POST
path: "/api/v1/watches"
tags: ["Board Watches"]
---

# Create a board watch

`POST /api/v1/watches`

## Request

- Base URL: `https://api.vaquill.ai`
- URL: `https://api.vaquill.ai/api/v1/watches`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-API-Key`
- TypeScript: <https://skmtc.dev/vaquill/apis/vaquill-developer-api/docs/api/v1/watches/post.ts> (a function with typed inputs; its response is checked against the schema)

Subscribe to a board. `webhookUrl` is validated against an SSRF blocklist (loopback, private, link-local and cloud-metadata ranges) before acceptance and again on every dispatch. Every `board.updated` delivery body carries a `deliveryId`, stable per watch per refresh event even across our own retries, so you can dedupe on it.

Each item in the body's `changes[]` carries the same `id` and `detectedAt` as `GET /watches/{watchId}/changes`. Pass its `id` as `sinceId` to page the rest of a change set that overflowed `changesOverflowCount`, or to `GET /watches/{watchId}/changes/{changeId}/diff` for the before and after text.

The body carries two sets of counters over different populations. Read `sections*`, and read `rows*` only if you already built on it:

- `sectionsAdded` / `sectionsAmended` / `sectionsRemoved` count sections, one per change event, the same unit as `changes[]`. They are narrowed by this watch's `scope`, which the body echoes back, and omitted when the refresh classified no change events.
- `rowsAdded` / `rowsUpdated` / `rowsDeleted` are the refresh's raw storage counters for the whole board. They are never narrowed by `scope` and do not share a unit with each other: added and deleted count storage chunks, updated counts sections. A watch scoped to 21 CFR part 314 can receive `rowsUpdated: 480` next to `sectionsAmended: 1`. Do not present the `rows*` keys as three populations of sections.

Free.

## Request body

- CreateWatchRequest
  - `corpusType` 'agency_adjudication' | 'agency_guidance' | 'agency_manuals' | 'cfr' | 'federal_local_rules' | 'federal_primary_law' | 'federal_register' | 'federal_register_notice' | 'federal_rules' | 'session_law' | 'state' | 'state_ag_opinion' | 'state_constitution' | 'state_insurance_bulletins' | 'state_regulation' | 'state_rules' | 'statute_compilation' | 'usc', required: Board's corpus_type (e.g. `state`, `state_regulation`, `federal_register`, `agency_guidance`). One of the published values; anything else is a 422 that lists them. Matched case-insensitively, and the uppercase search-style token for the same body of law (`USC`, `CFR`, `REGULATION`) is accepted and stored as the board key (`REGULATION` becomes `state_regulation`). This is the board vocabulary, not the `/us/statutes/search` `corpusType` token set: the names overlap where spelled alike (`usc`, `cfr`, `state`) and differ elsewhere. `GET /boards` says which (corpusType, state) pairs are live.
  - `state` 'federal' | 'al' | 'ak' | 'as' | 'az' | 'ar' | 'ca' | 'co' | 'ct' | 'de' | 'dc' | 'fl' | 'ga' | 'gu' | 'hi' | 'id' | 'il' | 'in' | 'ia' | 'ks' | 'ky' | 'la' | 'me' | 'md' | 'ma' | 'mi' | 'mn' | 'ms' | 'mo' | 'mt' | 'ne' | 'nv' | 'nh' | 'nj' | 'nm' | 'ny' | 'nc' | 'nd' | 'mp' | 'oh' | 'ok' | 'or' | 'pa' | 'pr' | 'ri' | 'sc' | 'sd' | 'tn' | 'tx' | 'ut' | 'vt' | 'vi' | 'va' | 'wa' | 'wv' | 'wi' | 'wy', nullable: Board's state (2-letter, case-insensitive). For a federal board (USC, eCFR, the Federal Register) pass `federal` or omit this entirely; the two are equivalent. Must otherwise match the `state` GET /boards returned for this corpusType: `GET /boards?corpusType=<type>` lists exactly those.
  - `channel` 'webhook' | 'email' | 'both'
  - `webhookUrl` string, nullable: Required when channel is webhook or both.
  - `webhookSecret` string, nullable: Optional signing secret, stored encrypted and never returned. When set, every delivery carries an `X-Vaquill-Signature: sha256=<hex>` header: HMAC-SHA256 of the raw request body bytes, keyed with this secret. To verify, compute the same HMAC over the raw body you received (before parsing JSON) and compare it, constant-time, to the hex digest after `sha256=`. Deliveries also carry Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64>`), signed over `{webhook-id}.{webhook-timestamp}.{raw body}`; prefer these, as they add replay protection. The HMAC key is the base64 decoding of the secret after `whsec_` when it has that prefix, otherwise the secret's UTF-8 bytes. `X-Vaquill-Signature` is deprecated but remains in place for at least 12 months.
  - `emailAddress` string, nullable: Required when channel is email or both.
  - `scope` object, nullable: Optional narrowing so this alert covers one citation instead of an entire source. Four mutually exclusive forms. Omit entirely to watch the whole source. Hierarchy prefix: keys `title`, `chapter`, `part`, `section`, e.g. `{"title": "21", "part": "314"}` for 21 CFR part 314. Every level you set must match. `title` is required whenever any narrower level is set, and `section` also needs `chapter` or `part`. Sources without a title/chapter/part hierarchy return 400 (see `scopable` on `GET /boards`). Exact section: `{"actId": "CFR_T21_P314_S314_50"}`, using the `actId` from search results and change events. Case-sensitive and validated at create time: an act_id we do not hold, or one from a different source, is a 400. Works on every source, and is the only way to follow a single Federal Register document. Named source: `{"source": "fdic_fil"}`, for corpora that fold several independent bodies of law behind one `corpusType`. The `agency_guidance` board alone carries 47 named sources across 24 agencies, so an unscoped watch on it delivers FDIC letters, IRS notices, OFAC FAQs and DOE appliance standards together. Accepted on `agency_adjudication`, `agency_guidance`, `agency_manuals`, `cfr`, `federal_rules`; anything else returns 400. Pickable values are on the board's `sources` array in `GET /boards`. Publishing agency: `{"agency": "irs"}`, covering every source that agency issues on the board (the IRS alone publishes 5 named series). Expanded when an event is matched, not when the watch is created, so a source registered later is covered automatically. Accepted on `agency_adjudication`, `agency_guidance`, `agency_manuals`; pickable values are on the board's `agencies` array in `GET /boards`.
  - `webhookAuth` WebhookAuthRequest: How a delivery should authenticate itself to your endpoint. SEPARATE FROM `webhookSecret`, and the distinction is the point. `webhookSecret` signs the body so you can prove it is intact and ours. This sends a credential so your gateway can reject anything else before it reaches your handler. Most integrations want the second, many want both, and the two are set independently.
    - `scheme` 'none' | 'bearer' | 'basic' | 'header': `bearer` sends `Authorization: Bearer <secret>`. `basic` sends `Authorization: Basic <secret>` with the secret already base64-encoded by you. `header` sends `<headerName>: <secret>`, for gateways that read something like `X-Api-Key`. `none` removes any credential currently stored.
    - `secret` string, nullable: The credential value. Stored encrypted and never returned. Required when setting a scheme for the first time or changing scheme; on PATCH you may omit it to keep the stored one while changing only `headerName`.
    - `headerName` string, nullable: Header to send the credential on. Required for `header`, and rejected for every other scheme. Cannot be `Authorization` (use `bearer`/`basic`), a transport header, or one of ours.

## Response `201`

The created watch.

- WatchEnvelope
  - `data` WatchResponse, required
    - `id` string, required
    - `corpusType` string, required
    - `state` string, nullable
    - `channel` string, required
    - `webhookUrl` string, nullable
    - `emailAddress` string, nullable
    - `scope` object, nullable
    - `isActive` boolean, required
    - `createdAt` string, required
    - `updatedAt` string, nullable
    - `lastNotifiedAt` string, nullable
    - `lastStatus` integer, nullable
    - `webhookAuth` WebhookAuthResponse: Auth CONFIG as read back. Never includes the credential itself.
      - `scheme` string, required
      - `headerName` string, nullable
      - `hasSecret` boolean
    - `cadence` string, nullable: How often we poll this board's source: `daily`, `weekly`, `monthly` or `annual`. Same value as `GET /boards`. Watching does not change it, and checking `/changes` more often than this gains nothing.
    - `lastRetrievedAt` string, nullable: ISO-8601 UTC timestamp of the last refresh run for this board that finished cleanly. A run that found nothing new counts.
    - `changeSignal` string: `continuous` or `edition_only`. On an `edition_only` board we can only detect a new edition arriving, so silence on this watch means no new edition, not that the law is unchanged. Full definition on `GET /boards`.
  - `meta` ResponseMeta, required: The `meta` block every board-watch route returns beside `data`.
    - `processingTimeMs` number, required
    - `creditsConsumed` number, required
    - `cursor` integer, nullable: Change feeds only: the value to send back as `sinceId`. Null on an empty page. The highest change `id` on the page, except on a full newest-first page read with a `sinceId`, where unread rows sit below the page and the cursor stays at your `sinceId`; use `nextBeforeId` to reach them.
    - `nextBeforeId` integer, nullable: Change feeds only, full `order=desc` pages: the lowest change `id` on the page. Send it as `beforeId` for the next older page. Null otherwise.
    - `hasMore` boolean, nullable: Change feeds only: true when the page filled to `limit`.

## Other responses

- `400`: Invalid request (bad channel/URL combo, rejected webhook target, or an invalid or unsupported scope).
- `401`: Invalid or missing API key.
- `403`: API key lacks `research:read` scope, or the plan does not include webhook delivery (Business only; email delivery is available on every plan).
- `404`: No enabled board for that corpusType + state.
- `409`: You already have a watch on this board for this channel.
- `422`: Validation error: `detail` plus one `errors` entry per rejected field.
- `429`: Rate limit exceeded (`rate_limited`, retry after `Retry-After`), or you have reached the maximum number of watches your plan allows (3 outside the Business plan): that is code `quota_exceeded` with `retryable: false`. Delete a watch or upgrade to raise it.
- `500`: Internal error (`internal_error`, retryable). Any credits charged for the call are refunded; retry with backoff and quote `requestId` to support if it persists.

## Changes

- **2026-11-07** `340c0d24d0b6`: 1 breaking, 83 info
  - request property `corpusType` was restricted to a list of enum values
  - added the new `agency_adjudication` enum value to the request property `corpusType`
  - added the new `agency_guidance` enum value to the request property `corpusType`
  - added the new `agency_manuals` enum value to the request property `corpusType`
  - …80 more

[Operation history](https://skmtc.dev/vaquill/apis/vaquill-developer-api/changes/api/v1/watches/post.md)

---

[API](https://skmtc.dev/vaquill/apis/vaquill-developer-api.md) - [All operations](https://skmtc.dev/vaquill/apis/vaquill-developer-api/llms.txt) - [OpenAPI document](https://skmtc.dev/vaquill/apis/vaquill-developer-api/revisions/340c0d24d0b6?raw)
