Skip to main content
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 costs 4 credits per call, because it reconstructs both sides of the text. Everything else is authenticated and rate-limited but not credit-metered.
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.

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: every state statute board declares monthly, and the 24 bulk-sourced jurisdictions listed under Which statute cadence applies where are in fact re-pulled every 7 days. 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 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 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.
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.
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.

Create a watch

POST /watches with the board’s corpusType (and state, for a state board) plus a channel.
  • 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.
  • 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:
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.
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.
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:
  • 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.
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.

Authenticating our deliveries

There are two independent mechanisms, and choosing between them starts with noticing they answer different questions. Most teams reach for webhookAuth first, because it works with middleware they already have. Set neither, either, or both.
cURL
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.
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:
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.
deliveryId is stable per watch per refresh event, even across our own internal retries, so you can dedup safely on your end.

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. 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
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.
cURL
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).

Polling

You can poll this endpoint as often as you like, including alongside a webhook or email watch.
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.)
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.
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
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.
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.
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: 4 credits, charged whether or not both sides are available, so branch on hasDiff before spending on it.
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.
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.

Monitor corpus coverage

A DIY alternative for detecting when a jurisdiction gains an entirely new corpus, rather than a section-level change within one you already watch.

Rate limits

Per-plan limits and how to back off on a 429.

Errors

How error responses are shaped, and what each status code means.

Coverage

The jurisdiction and corpus matrix you’re choosing a board from.
Last modified on September 7, 2026