# Authentication & tiers ## API keys A key is the string `wch_` followed by 43 URL-safe base64 characters (256 bits of entropy). Mint and revoke keys under [**Account → API keys**](https://www.wolfofcapitolhill.com/account/api-keys). - The plaintext is shown **once**, at creation. Only its SHA-256 hash is stored, so a lost key cannot be recovered — mint a new one. - The dashboard lists each key by its first 12 characters, name, creation time, and last use. - Revocation is immediate. Revoked keys stay listed for audit. - How many keys you can hold at once is set by your plan (below); hitting the cap returns `403 api_key_limit_reached` with your plan's `limit`. ## Sending the key Two equivalent headers; use whichever fits your HTTP client: ```bash # Preferred curl -H "Authorization: Bearer wch_YOUR_KEY" \ "https://www.wolfofcapitolhill.com/api/v1/trades" # Alternative curl -H "X-Api-Key: wch_YOUR_KEY" \ "https://www.wolfofcapitolhill.com/api/v1/trades" ``` When both headers are present, `Authorization` wins. ## Anonymous access Requests without a key are allowed for evaluation: **10 requests/minute and 1,000/month**, tracked against a salted hash of your client address (the raw IP is never stored). Anonymous callers cannot use CSV export. Everything else behaves identically, so you can prototype before subscribing. ## Plans and their limits Subscriptions are managed under [**Account → Billing**](https://www.wolfofcapitolhill.com/account/billing). The API is included in **Pack** and **Den**; Scout and Wolf are dashboard-focused plans. | Limit | Scout (free) | Wolf | Pack | Den | | --- | --- | --- | --- | --- | | API requests / minute | — | — | 60 | 300 | | API requests / month | — | — | 100,000 | 1,000,000 | | Active API keys | 0 | 0 | 3 | 10 | | Webhook endpoints | 0 | 0 | 3 | 10 | | Alert subscriptions | 0 | 25 | 100 | 500 | | CSV export | No | Yes | Yes | Yes | | Dashboard data delay | 48 h | Real-time | Real-time | Real-time | | Commercial-use license | No | No | No | Yes | Notes: - **Wolf includes CSV export in the dashboard** but no API keys, so the API's `format=csv` is effectively a Pack/Den feature (a keyed caller's plan must include CSV export; anonymous callers never have it). - **Commercial use** — redistributing the data or embedding it in a paid product — requires **Den**. - Rate limits are enforced **per key** (each key gets its own minute window), so you can isolate workloads by minting one key per service. ## Auth errors | Status | `error` code | Meaning | | --- | --- | --- | | `401` | `invalid_api_key` | The presented key is unknown or revoked. | | `403` | `plan_has_no_api_access` | The key's owner is on a plan without API access. The `hint` field says how to upgrade. | | `403` | `csv_export_not_available` | `format=csv` requested on a plan without CSV export. | | `403` | `api_key_limit_reached` | Minting one more key would exceed the plan's cap (`limit` is included). | A `403 plan_has_no_api_access` response looks like: ```json { "error": "plan_has_no_api_access", "hint": "API access requires the Pack plan or higher. See /pricing to upgrade." } ``` --- # Getting started ## 1. Try it without a key The API works anonymously at evaluation limits (10 requests/minute, 1,000/month, JSON only), so your first request needs nothing but `curl`: ```bash curl "https://www.wolfofcapitolhill.com/api/v1/trades?limit=5" ``` ```json { "data": [ { "id": "trd_01j8...", "politician": { "slug": "jane-doe", "name": "Jane Doe", "chamber": "senate", "party": "D", "state": "CA" }, "ticker": "NVDA", "assetName": "NVIDIA Corporation", "assetType": "stock", "side": "buy", "amountBucket": "15k_50k", "amountRange": { "min": 15001, "max": 50000, "label": "$15,001 – $50,000" }, "tradeDate": "2026-06-24", "disclosedAt": "2026-06-30T14:05:00.000Z", "filingUrl": "https://efdsearch.senate.gov/search/view/ptr/...", "ownerType": "self", "conflictSignal": true, "conflictReason": "Serves on the Senate Commerce Committee (technology oversight); traded a semiconductor issuer." } ], "nextCursor": "MjAyNi0wNi0zMFQxNDowNTowMC4wMDBafHRyZF8wMWo4" } ``` ## 2. Create an account and mint a key API keys are included in the **Pack** and **Den** plans (see [Authentication & tiers](/docs/authentication) for the exact limits). 1. Sign up at [wolfofcapitolhill.com/account](https://www.wolfofcapitolhill.com/account). 2. Subscribe to Pack or Den under **Account → Billing**. 3. Mint a key under **Account → API keys**. Keys look like `wch_` followed by 43 URL-safe characters. The full secret is shown **exactly once** at creation — only its hash is stored, so it cannot be retrieved again. Store it in a secret manager and rotate by minting a new key and revoking the old one. ## 3. Make an authenticated request Send the key as a bearer token (or in an `X-Api-Key` header — see [Authentication](/docs/authentication)): ```bash curl -H "Authorization: Bearer wch_YOUR_KEY" \ "https://www.wolfofcapitolhill.com/api/v1/trades?ticker=NVDA&limit=25" ``` The same request from JavaScript: ```js const BASE_URL = 'https://www.wolfofcapitolhill.com'; const response = await fetch(`${BASE_URL}/api/v1/trades?ticker=NVDA&limit=25`, { headers: { Authorization: `Bearer ${process.env.WOCH_API_KEY}` }, }); if (!response.ok) { // Errors are always { error, hint? } with the right status code. const { error, hint } = await response.json(); throw new Error(`WoCH API ${response.status}: ${error}${hint ? ` — ${hint}` : ''}`); } const { data, nextCursor } = await response.json(); console.log(`${data.length} trades; remaining this minute:`, response.headers.get('X-RateLimit-Remaining')); ``` ## 4. Walk the feed Every list response includes `nextCursor`. Pass it back as `cursor` to get the next page; `null` means you have reached the end: ```js async function* allTrades(params = {}) { let cursor; do { const query = new URLSearchParams({ ...params, limit: '100' }); if (cursor) query.set('cursor', cursor); const response = await fetch(`${BASE_URL}/api/v1/trades?${query}`, { headers: { Authorization: `Bearer ${process.env.WOCH_API_KEY}` }, }); if (!response.ok) throw new Error(`WoCH API ${response.status}`); const page = await response.json(); yield* page.data; cursor = page.nextCursor; } while (cursor !== null); } for await (const trade of allTrades({ chamber: 'senate' })) { if (trade.conflictSignal) console.log(trade.politician.name, trade.ticker, trade.conflictReason); } ``` For incremental syncs, filter with `since` (an ISO 8601 instant with offset, e.g. `2026-07-01T00:00:00Z`) instead of re-walking history — or skip polling entirely and let [webhooks](/docs/webhooks) push new trades to you. ## Where to go next - [Rate limits & pagination](/docs/rate-limits) — budgets, `429` handling, cursor rules. - [The trades data model](/docs/trades-data) — what every field means (and the amount-range gotcha). - [Webhooks](/docs/webhooks) — signed pushes the moment trades publish. --- # Wolves of Capitol Hill API The Wolves of Capitol Hill public data API serves **published congressional stock-trade disclosures**: every trade extracted from House and Senate periodic transaction reports (PTRs), reviewed by a human, and enriched with **committee conflict signals** — flags on trades that overlap an industry the disclosing member oversees. ## Base URL ``` https://www.wolfofcapitolhill.com ``` All endpoints live under `/api/v1`. You can start **without an API key** — anonymous callers get evaluation-grade rate limits — and add a key from your [account dashboard](https://www.wolfofcapitolhill.com/account) for production limits. ## Endpoints | Endpoint | What it returns | | --- | --- | | `GET /api/v1/trades` | The published-trades feed: filterable, keyset-paginated, optional CSV export. | | `GET /api/v1/politicians/{slug}` | One member's profile, recent trades, and counts. | | `GET /api/v1/tickers/{symbol}` | Congressional activity in one symbol, with buy/sell aggregates. | | `GET /api/v1/filings` | The published source filings, with official document URLs. | The machine-readable spec is served at [`/api/v1/openapi.json`](https://www.wolfofcapitolhill.com/api/v1/openapi.json), and these docs are available as one plain-text dump at [`/llms-full.txt`](/llms-full.txt) for LLM ingestion. ## Response shapes List endpoints return a page plus an opaque cursor: ```json { "data": [ /* ... */ ], "nextCursor": "MjAyNi0wNi0zMFQ..." } ``` Single-resource endpoints wrap their payload in `data`. Errors are always: ```json { "error": "machine_readable_code", "hint": "Optional human guidance." } ``` Every response — success or failure — carries `X-RateLimit-*` headers. ## Next steps - **[Getting started](/docs/getting-started)** — account, key, first request. - **[Authentication & tiers](/docs/authentication)** — keys, plans, and their real limits. - **[Rate limits & pagination](/docs/rate-limits)** — the minute/month windows and cursor semantics. - **[The trades data model](/docs/trades-data)** — every `Trade` field, STOCK Act amount ranges, conflict signals. - **[Webhooks](/docs/webhooks)** — signed `trade.published` pushes to your server. - **[MCP server](/docs/mcp)** — the API as tools in Claude Desktop, Claude Code, or any MCP client. - **API Reference** — every endpoint, generated from the OpenAPI spec, in the sidebar. --- # MCP server `@sitekit/woch-mcp` is a stdio [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the public data API as read-only tools: trades, politician profiles, per-ticker activity, and source filings. It works **without an API key** at anonymous evaluation limits; set one for production limits. ## Install / run From the monorepo: ```bash pnpm install pnpm --filter @sitekit/woch-mcp build pnpm --filter @sitekit/woch-mcp start # stdio server on stdin/stdout ``` The built entry point lands at `apps/mcp/dist/index.js` (the package declares a `woch-mcp` bin for when it ships to npm; until then run the local build). ## Environment | Variable | Required | Default | Purpose | | --- | --- | --- | --- | | `WOCH_API_URL` | no | `https://www.wolfofcapitolhill.com` | The WoCH deployment to call. | | `WOCH_API_KEY` | no | — | API key (`wch_...`) from [Account → API keys](https://www.wolfofcapitolhill.com/account/api-keys), sent as `Authorization: Bearer`. Omit to evaluate anonymously at low limits. | ## Client configuration **Claude Desktop** (`claude_desktop_config.json`): ```json { "mcpServers": { "wolves-of-capitol-hill": { "command": "node", "args": ["/path/to/wolves-of-capitol-hill/apps/mcp/dist/index.js"], "env": { "WOCH_API_KEY": "wch_..." } } } } ``` **Claude Code**: ```bash claude mcp add wolves-of-capitol-hill \ --env WOCH_API_KEY=wch_... \ -- node /path/to/wolves-of-capitol-hill/apps/mcp/dist/index.js ``` Drop the `WOCH_API_KEY` line entirely to evaluate anonymously. ## Tools | Tool | What it does | | --- | --- | | `list_trades` | Published trades, newest first, keyset-paginated. Filters AND-stack: `ticker`, `politician_slug`, `chamber`, `since` (ISO 8601), `limit` (≤100), `cursor`. | | `get_politician` | One politician's profile + recent trades + counts, by slug. | | `get_ticker` | Congressional activity in one symbol: recent trades + aggregates (buys/sells/lastActivity). | | `list_filings` | Published disclosure filings with official source URLs (`chamber`, `limit`, `cursor`). | | `find_politician` | Free-text name → ranked slug matches. The API has no name-search endpoint, so this scans up to `max_pages` × 100 recent trades (default 3, max 8) and fuzzy-matches the distinct politicians found — members with no recent published trades may not appear. | | `get_conflicted_trades` | Trades with `conflictSignal=true`, filtered client-side over up to `max_pages` × 100 feed rows (default 3, hard cap 5); returns `nextCursor` to continue scanning. | ## Semantics the tools spell out for the model - **Amounts are STOCK Act ranges.** `amountBucket`/`amountRange` are the statutory disclosure ranges (e.g. `$1,001 – $15,000`) — members never disclose exact figures; `amountRange.max` is `null` when unbounded above. See [the trades data model](/docs/trades-data). - **`conflictSignal`** flags a trade overlapping an industry the politician oversees via committee assignments (`conflictReason` says why). It is an editorial signal, not an allegation of illegality. - **Errors are readable.** `401` → check `WOCH_API_KEY`; `403` → the plan's upgrade hint; `429` → retry-after seconds; a malformed cursor → "pass the exact `nextCursor`". Anonymous callers get a `rateLimitWarning` in results when the minute window runs low. --- # Rate limits & pagination ## Two windows Every request consumes from two fixed windows: 1. a **minute burst window** (10/min anonymous, 60/min Pack, 300/min Den), and 2. 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 ``` ```json { "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." ```js 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` — the `nextCursor` from 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. ```bash 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`/`since` mid-walk changes what the cursor pages over. - **A malformed or truncated cursor is `400 invalid_cursor`** — pass the exact `nextCursor` string. - 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: ```bash 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](/docs/webhooks) push `trade.published` events to your server the moment filings publish. --- # The trades data model Every trade in the API went through the same pipeline: scraped from an official House or Senate disclosure, extracted, **human-reviewed**, and only then published. The `Trade` object is identical everywhere it appears — the feed, politician profiles, ticker pages, and webhook payloads. ## Trade fields | Field | Type | Meaning | | --- | --- | --- | | `id` | `string` | Stable trade id. | | `politician` | `object` | The disclosing member: `slug`, `name`, `chamber` (`house` \| `senate`), `party`, `state` (the last two nullable). | | `ticker` | `string \| null` | Exchange symbol, uppercase. **Nullable — see below.** | | `assetName` | `string` | The asset as written in the filing. | | `assetType` | `string \| null` | Asset class as disclosed (e.g. stock, bond, fund), when stated. | | `side` | `string` | `buy`, `sell_full`, `sell_partial`, or `exchange`. | | `amountBucket` | `string` | The statutory range key (e.g. `15k_50k`) — see the table below. | | `amountRange` | `object` | The bucket resolved to `{ min, max, label }`; `max` is `null` when unbounded. | | `tradeDate` | `string \| null` | The transaction date (`yyyy-mm-dd`), when the filing states it. | | `disclosedAt` | `string` | ISO 8601 instant the trade was disclosed. Feed ordering key. | | `filingUrl` | `string \| null` | The official source document URL. | | `ownerType` | `string \| null` | Who holds the asset: `self`, `spouse`, `joint`, or `child`; `null` when not stated. | | `conflictSignal` | `boolean` | Whether the trade overlaps an industry the member oversees — see below. | | `conflictReason` | `string \| null` | Why the signal fired; `null` when it did not. | ## Amounts are ranges, not numbers Members of Congress **never disclose exact amounts** — the STOCK Act disclosure forms use checkbox ranges. `amountBucket` is the range key and `amountRange` spells out the statutory bounds: | `amountBucket` | Range | | --- | --- | | `1k_15k` | $1,001 – $15,000 | | `15k_50k` | $15,001 – $50,000 | | `50k_100k` | $50,001 – $100,000 | | `100k_250k` | $100,001 – $250,000 | | `250k_500k` | $250,001 – $500,000 | | `500k_1m` | $500,001 – $1,000,000 | | `1m_5m` | $1,000,001 – $5,000,000 | | `5m_25m` | $5,000,001 – $25,000,000 | | `25m_50m` | $25,000,001 – $50,000,000 | | `over_50m` | Over $50,000,000 (`max: null`) | | `over_1m_spouse` | Over $1,000,000, spouse/dependent holdings (`max: null`) | Two consequences for consumers: - Never treat `amountRange.min` (or the midpoint) as "the amount" in analytics without labeling it as a bound — a `1m_5m` trade could be $1,000,001 or $4,999,999. - `amountRange.max` is `null` for the two open-ended buckets; handle that before doing arithmetic. The `over_1m_spouse` bucket exists because the House form has a distinct "over $1,000,000" checkbox for spouse/dependent-held assets that doesn't state an upper bound. ## Conflict signals `conflictSignal` is the platform's editorial flag: `true` when the traded asset's industry **overlaps a committee the member sits on** (e.g. a Senate Armed Services member trading a defense contractor). `conflictReason` explains the overlap in one sentence. How it's computed, so you know what you're consuming: - Committee assignments and memberships come from public congressional datasets; tickers map to sectors/industries with curated overrides. - A daily sweep **recomputes the verdict for every trade** — signals can appear or be corrected after first publication as committee data updates, so treat the pair as mutable metadata, not immutable history. - It is an **editorial signal of potential conflict of interest, not an allegation of illegality** — STOCK Act trading is legal when disclosed. Present it accordingly. ## The nullable-ticker caveat `ticker` is `null` on a meaningful share of real trades: municipal bonds, private funds, crypto, real-estate partnerships, and assets whose symbol can't be resolved from the filing text. Two practical rules: - Code that groups or joins by ticker must handle `null` (use `assetName` as the display fallback). - The feed's `ticker=` filter only matches trades whose symbol resolved — it will never return the `null`-ticker rows. `tradeDate` and `filingUrl` are nullable for the same underlying reason: the API reports exactly what the filing supports, never a guess. ## CSV export The trades feed can answer CSV for offline analysis (plans with CSV export; otherwise `403 csv_export_not_available`): ```bash curl -H "Authorization: Bearer wch_YOUR_KEY" \ "https://www.wolfofcapitolhill.com/api/v1/trades?format=csv&limit=100" > trades.csv ``` Columns, in order: `id`, `politician_slug`, `politician_name`, `chamber`, `party`, `state`, `ticker`, `asset_name`, `asset_type`, `side`, `amount_bucket`, `amount_min`, `amount_max`, `trade_date`, `disclosed_at`, `filing_url`, `owner_type`. Null fields are empty strings, values are RFC-4180 quoted, and pagination works exactly as in JSON (`limit` + `cursor` still apply; the response is one page, not the whole feed). Note the CSV omits `conflictSignal` / `conflictReason` — use the JSON format when you need them. --- # Webhooks Webhooks push events to your server as **signed HTTP POSTs** the moment they happen, so you never have to poll. One event type ships today: - **`trade.published`** — fired when a filing's trades go public, carrying the filing, the member, and every trade it published. Webhook endpoints are included in **Pack (3 endpoints)** and **Den (10)**; manage them under [**Account → Webhooks**](https://www.wolfofcapitolhill.com/account/webhooks). Each endpoint gets its own signing secret, shown in the dashboard. ## The delivery request Every delivery is a `POST` with `Content-Type: application/json` and three headers: | Header | Meaning | | --- | --- | | `X-WCH-Signature` | `t=,v1=` — verify before trusting the body. | | `X-WCH-Event` | The event type, e.g. `trade.published`. | | `X-WCH-Delivery-Id` | Unique per delivery. **Dedupe on this** — retries reuse the id. | ### `trade.published` payload ```json { "event": "trade.published", "filing": { "id": "fil_01j8...", "chamber": "senate", "url": "https://efdsearch.senate.gov/search/view/ptr/...", "filedAt": "2026-06-30T13:58:00.000Z" }, "politician": { "slug": "jane-doe", "name": "Jane Doe", "chamber": "senate", "party": "D", "state": "CA" }, "trades": [ { "id": "trd_01j8...", "politician": { "slug": "jane-doe", "name": "Jane Doe", "chamber": "senate", "party": "D", "state": "CA" }, "ticker": "NVDA", "assetName": "NVIDIA Corporation", "assetType": "stock", "side": "buy", "amountBucket": "15k_50k", "amountRange": { "min": 15001, "max": 50000, "label": "$15,001 – $50,000" }, "tradeDate": "2026-06-24", "disclosedAt": "2026-06-30T14:05:00.000Z", "filingUrl": "https://efdsearch.senate.gov/search/view/ptr/...", "ownerType": "self", "conflictSignal": true, "conflictReason": "Serves on the Senate Commerce Committee (technology oversight); traded a semiconductor issuer." } ] } ``` One event covers **all** trades a filing published (often several), and each trade matches the API's [`Trade` shape](/docs/trades-data) exactly. ## Verifying the signature The signature is `HMAC-SHA256(secret, ".")`, hex-encoded, over the **exact raw body bytes** — parse the JSON only *after* verifying. This is the canonical verification snippet: ```js const crypto = require('node:crypto'); function verifyWchSignature(secret, rawBody, signatureHeader) { // signatureHeader: "t=,v1=" const parts = Object.fromEntries( signatureHeader.split(',').map((p) => p.split('=')), ); const expected = crypto .createHmac('sha256', secret) .update(`${parts.t}.${rawBody}`) .digest('hex'); return ( expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)) ); } ``` Wired into an Express receiver (note `express.raw` — a JSON body parser would re-serialize the body and break the signature): ```js const express = require('express'); const app = express(); app.post('/webhooks/woch', express.raw({ type: 'application/json' }), (req, res) => { const header = req.header('X-WCH-Signature') ?? ''; const rawBody = req.body.toString('utf8'); if (!verifyWchSignature(process.env.WOCH_WEBHOOK_SECRET, rawBody, header)) { return res.status(400).send('bad signature'); } // Replay protection: reject stale timestamps (deliveries are signed at // send time; 5 minutes of clock skew is the recommended tolerance). const timestamp = Number(header.split(',')[0].slice(2)); if (Math.abs(Date.now() / 1000 - timestamp) > 300) { return res.status(400).send('stale signature'); } const event = JSON.parse(rawBody); // Ack fast, process async — do the real work off the request path. res.status(204).end(); processTradePublished(event, req.header('X-WCH-Delivery-Id')); }); ``` ## Respond fast, retries, and endpoint health - **Respond with any `2xx` within 10 seconds** (the delivery timeout). Queue heavy work and ack immediately. - Anything else — non-2xx, timeout, connection error — schedules a retry on a fixed backoff: **30 s → 5 min → 30 min → 2 h → 12 h**. After the schedule is exhausted the delivery is parked as failed. - Retries reuse the same `X-WCH-Delivery-Id`; make your processing **idempotent** on it. - An endpoint that fails **10 times in a row is auto-disabled** (no further deliveries) until you re-enable it in the dashboard. A `2xx` at any point resets the counter. ## Testing locally Point an endpoint at a tunnel (e.g. `ngrok http 3000`) from **Account → Webhooks**, then trigger a real event or replay a payload with your own signature: ```bash BODY='{"event":"trade.published","filing":{},"politician":{},"trades":[]}' T=$(date +%s) SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$WOCH_WEBHOOK_SECRET" -hex | sed 's/^.* //') curl -X POST http://localhost:3000/webhooks/woch \ -H "Content-Type: application/json" \ -H "X-WCH-Event: trade.published" \ -H "X-WCH-Delivery-Id: test-$T" \ -H "X-WCH-Signature: t=$T,v1=$SIG" \ -d "$BODY" ``` --- # Filings {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Politicians {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Tickers {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Trades {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}