Rate limits & pagination
The minute and month windows, X-RateLimit-* headers, 429 handling with Retry-After, and cursor pagination semantics.
Two windows
Every request consumes from two fixed windows:
- a minute burst window (10/min anonymous, 60/min Pack, 300/min Den), and
- a calendar-month quota (1,000 anonymous, 100,000 Pack, 1,000,000 Den).
Exceeding either returns 429. Keyed requests are metered per key;
anonymous requests are metered per (salted, hashed) client address.
The headers
Every response — success or failure — carries the minute window's state:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute for the caller. |
X-RateLimit-Remaining | Requests remaining in the current minute window. |
X-RateLimit-Reset | When the minute window resets (unix seconds). |
Handling 429
A denied request answers with Retry-After (seconds) and a machine-readable
body:
HTTP/1.1 429 Too Many Requests
Retry-After: 21
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1782043260{ "error": "rate_limited", "retryAfterSeconds": 21 }Back off for Retry-After seconds, then retry. All /api/v1 endpoints are
GET (idempotent), so retrying is always safe. A monthly-quota denial looks
the same but with a much larger Retry-After — treat any value over an hour
as "wait for the next month or upgrade."
async function wochFetch(url, init, { maxRetries = 3 } = {}) {
for (let attempt = 0; ; attempt += 1) {
const response = await fetch(url, init);
if (response.status !== 429 || attempt >= maxRetries) return response;
const retryAfter = Number(response.headers.get('Retry-After') ?? '1');
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
}
}Cursor pagination
List endpoints (/api/v1/trades, /api/v1/filings) use keyset pagination
ordered by (disclosedAt desc, id desc) — newest disclosures first.
limit— rows per page, 1–100, default 25.cursor— thenextCursorfrom the previous response, passed back verbatim. It is an opaque token; don't parse or construct it.nextCursor: null— you're on the last page.
curl "https://www.wolfofcapitolhill.com/api/v1/trades?limit=100"
# → { "data": [...], "nextCursor": "MjAyNi0wNi0zMFQ..." }
curl "https://www.wolfofcapitolhill.com/api/v1/trades?limit=100&cursor=MjAyNi0wNi0zMFQ..."Rules that keep a walk correct:
- Keep the filters identical across pages. The cursor only encodes the
keyset position, not your query — changing
ticker/chamber/sincemid-walk changes what the cursor pages over. - A malformed or truncated cursor is
400 invalid_cursor— pass the exactnextCursorstring. - Keyset pagination is stable under concurrent inserts: rows published while you paginate never shift your pages (new rows appear before your first page, not inside your walk).
Incremental sync with since
For "give me everything new since my last poll," filter with since (ISO
8601 instant, offset required — Z works) instead of diffing full walks:
curl "https://www.wolfofcapitolhill.com/api/v1/trades?since=2026-07-01T00:00:00Z&limit=100"Record the newest disclosedAt you've seen, use it as the next poll's
since, and page through with cursor if a poll returns a full page. If you
would rather not poll at all, webhooks push
trade.published events to your server the moment filings publish.