How to set up SEC filing webhooks

Polling for SEC filings works, but webhooks are better — EdgarKit pushes each filing to your server within seconds of SEC acceptance, no polling loop required. This guide walks through registering a webhook, verifying the HMAC SHA-256 signature on every payload, and handling retries gracefully.

The problem

Polling the API every 60 seconds works fine for low-urgency use cases. But if you're building something that reacts to Form 4 insider buys or 8-K earnings releases, 60-second lag is too slow. The market has already moved.

Webhooks invert the model: instead of you asking "anything new?", EdgarKit asks "can you handle this now?" You get a POST to your URL within seconds of the filing landing on EDGAR. No polling loop, no missed filings between intervals.

The approach

Setting up webhooks has three steps:

  1. Stand up an HTTP endpoint that accepts POST requests
  2. Register that endpoint with EdgarKit, specifying which form types and tickers to watch
  3. Verify the HMAC SHA-256 signature on each incoming request before trusting the payload

The third step is the one developers most often skip. Don't skip it — your endpoint is a public URL, and without signature verification, anyone can post fake filing events to it.

Step 1: Create a webhook subscription

POST to /v1/webhooks with the form types, tickers, and a signing secret you choose:

curl -X POST "https://api.edgarkit.com/v1/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/edgarkit",
    "form_types": ["4", "8-K", "10-Q"],
    "tickers": ["NVDA", "AAPL", "TSLA"],
    "secret": "a_long_random_string_you_generate"
  }'

Use a cryptographically random secret — at least 32 bytes. In Python: secrets.token_hex(32). Store it in an environment variable, not in your code.

The response:

{
  "webhook_id": "wh_a1b2c3d4e5",
  "status": "active",
  "url": "https://your-server.com/webhooks/edgarkit",
  "form_types": ["4", "8-K", "10-Q"],
  "tickers": ["NVDA", "AAPL", "TSLA"],
  "created_at": "2026-06-18T12:00:00Z"
}

Save the webhook_id — you'll need it to update or delete the subscription.

Step 2: Understand the signature header

Every request EdgarKit sends to your endpoint includes an X-EdgarKit-Signature header:

X-EdgarKit-Signature: sha256=3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4

It's a hex-encoded HMAC SHA-256 of the raw request body, signed with the secret you set when creating the webhook. The sha256= prefix is always present.

To verify: compute the HMAC yourself using the same secret and compare. If they match, the payload came from EdgarKit. If they don't, discard it and return 400.

Step 3: Build the Node.js endpoint

Here's a complete Express handler. The key detail is reading the raw body as a Buffer — once you call JSON.parse on it you've lost the exact bytes that were signed.

const express = require("express");
const crypto = require("crypto");

const app = express();
const WEBHOOK_SECRET = process.env.EDGARKIT_WEBHOOK_SECRET;

// Use raw body parser so we can verify the signature
app.use(
  "/webhooks/edgarkit",
  express.raw({ type: "application/json" })
);

function verifySignature(rawBody, signatureHeader) {
  if (!signatureHeader || !signatureHeader.startsWith("sha256=")) {
    return false;
  }
  const receivedSig = signatureHeader.slice("sha256=".length);
  const expectedSig = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");

  // Use timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(receivedSig, "hex"),
    Buffer.from(expectedSig, "hex")
  );
}

app.post("/webhooks/edgarkit", (req, res) => {
  const signature = req.headers["x-edgarkit-signature"];

  if (!verifySignature(req.body, signature)) {
    console.warn("Invalid webhook signature — rejected");
    return res.status(400).json({ error: "Invalid signature" });
  }

  const payload = JSON.parse(req.body.toString());
  const { filing } = payload;

  console.log(
    `[${filing.filed_at}] ${filing.form_type} — ${filing.issuer_ticker} — ${filing.accession_number}`
  );

  // Respond 200 quickly; do your heavy processing asynchronously
  res.status(200).json({ received: true });

  // Process async (push to queue, write to DB, fire alert, etc.)
  setImmediate(() => processFilingAsync(payload));
});

async function processFilingAsync(payload) {
  // Your logic here: store in DB, send Slack alert, trigger a backtest, etc.
  console.log("Processing:", payload.filing.id);
}

app.listen(3000, () => console.log("Webhook server listening on port 3000"));

Two important patterns here: respond 200 immediately (don't make EdgarKit wait while you hit your database), and use crypto.timingSafeEqual instead of === to prevent timing-based signature forging.

Step 4: Handle retries

EdgarKit retries undelivered webhooks with exponential backoff: 30 seconds, 2 minutes, 10 minutes, 1 hour, then every 6 hours up to 24 hours. A delivery is considered failed if your endpoint returns a non-2xx status code or times out after 10 seconds.

To make your handler idempotent, use the filing.id field as a deduplification key before doing any writes:

async function processFilingAsync(payload) {
  const filingId = payload.filing.id;

  // Check if already processed (pseudocode — adapt to your DB)
  const exists = await db.filings.findOne({ id: filingId });
  if (exists) {
    console.log(`Duplicate delivery for ${filingId}, skipping`);
    return;
  }

  await db.filings.insert({ id: filingId, data: payload.filing });
  // ... rest of your processing
}

This way, retries are harmless — the second and third delivery just hit the early return.

Putting it together

The full flow in one place: register once, verify every delivery, process async, dedupe on filing.id.

# 1. Register the webhook
curl -X POST "https://api.edgarkit.com/v1/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/edgarkit",
    "form_types": ["4", "8-K"],
    "tickers": ["NVDA", "AAPL", "TSLA"],
    "secret": "'"$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")"'"
  }'

# 2. Test with a replay (sends the last matched filing to your endpoint again)
curl -X POST "https://api.edgarkit.com/v1/webhooks/wh_a1b2c3d4e5/replay" \
  -H "Authorization: Bearer YOUR_API_KEY"

# 3. List active webhooks
curl "https://api.edgarkit.com/v1/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY"

# 4. Delete a webhook
curl -X DELETE "https://api.edgarkit.com/v1/webhooks/wh_a1b2c3d4e5" \
  -H "Authorization: Bearer YOUR_API_KEY"

FAQ

Do I need HTTPS for my webhook URL?

Yes. EdgarKit only delivers to HTTPS endpoints in production. For local development, use a tunneling tool like ngrok to expose your local server over HTTPS.

Why use HMAC instead of a shared token in the URL?

URL-based tokens can leak in server logs, browser history, and HTTP referrer headers. HMAC signatures are computed per-payload, so even if a request is logged, the signature can't be reused on a different payload.

What HTTP status code should I return?

Return 200 (or any 2xx) to acknowledge receipt. Return 4xx only if you want EdgarKit to treat the delivery as a permanent failure and stop retrying. Return 5xx if you want it to retry.

Can I subscribe to all form types at once?

Yes — omit the form_types field entirely to receive every form type for your tickers. Volume will be high for active tickers. Scoping to the specific form types your application actually uses is the right default.

How do I rotate my webhook secret without downtime?

EdgarKit supports dual secrets during rotation. POST to /v1/webhooks/{webhook_id} with a pending_secret field. EdgarKit will sign with both secrets for 24 hours, giving you time to update your verification logic to accept either one, then remove the old secret.