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:
| Header | Meaning |
|---|---|
X-WCH-Signature | t=<unix seconds>,v1=<hex HMAC> — 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
{
"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
2xxwithin 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
2xxat 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"