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 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 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.
Create a watch
POST /watches with the board’s corpusType (and state, for a state board) plus a channel.
channel:webhook,email, orboth.webhookUrlis required forwebhook/both;emailAddressis required foremail/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.webhookUrlis validated against an SSRF blocklist (loopback, private, and link-local ranges, plus cloud metadata endpoints) at creation and again on every dispatch.404if thecorpusType/statepair does not match a real, enabled board.409if you already have a watch on that board, for that channel, with that samescope. 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 ofcfr 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.
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.
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.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, newsecret. - Rename the header, keep the token: same
scheme, newheaderName, and omitsecret. - Change scheme: send
secretagain. 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 aPOST 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.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
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.)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.
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.
When hasDiff is true, call GET /watches/{watchId}/changes/{changeId}/diff for the section’s text before and after that specific change:
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.Related
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.

