Wolves of Capitol HillAPI Docs

Webhooks

Signed trade.published pushes to your server — payload shape, X-WCH-Signature verification, retries, and endpoint health.

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. 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:

HeaderMeaning
X-WCH-Signaturet=<unix seconds>,v1=<hex HMAC> — verify before trusting the body.
X-WCH-EventThe event type, e.g. trade.published.
X-WCH-Delivery-IdUnique per delivery. Dedupe on this — retries reuse the id.

trade.published payload

{
  "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 exactly.

Verifying the signature

The signature is HMAC-SHA256(secret, "<timestamp>.<rawBody>"), hex-encoded, over the exact raw body bytes — parse the JSON only after verifying. This is the canonical verification snippet:

const crypto = require('node:crypto');

function verifyWchSignature(secret, rawBody, signatureHeader) {
  // signatureHeader: "t=<unix seconds>,v1=<hex>"
  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):

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:

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"