Webhooks
When a sentinel matches a new SEC filing, edgar.tools delivers a JSON event to your endpoint with an HMAC-SHA256 signature so you can verify it came from us. Webhooks — configuration and delivery — are available on the Analyst plan and above.
Endpoints#
You configure endpoints at Settings → Webhooks. Each endpoint has:
- A POST URL (HTTPS, public host, no redirects)
- A signing secret (
edgar_whsec_…) shown once at creation and on every rotation - A status (
activeordisabled) and a per-sentinel health rollup
Sentinels reference an endpoint by id; one endpoint can serve many sentinels.

Headers#
Every delivery includes:
| Header | Description |
|---|---|
Content-Type | Always application/json |
X-Edgar-Event | Event type (e.g. filing.analyzed.v1) |
X-Edgar-Delivery | Unique event id — use this as your idempotency key |
X-Edgar-Signature | sha256=<hex> — HMAC-SHA256 of the raw body. During a rotation grace window, two signatures comma-joined: sha256=A,sha256=B |
X-Edgar-Signature-V2 | sha256=<hex> — HMAC-SHA256 of <X-Edgar-Timestamp>.<raw body>. Same secret and rotation behavior as X-Edgar-Signature; binds the timestamp into the signature so stale captures can't be replayed. Prefer this over X-Edgar-Signature. |
X-Edgar-Timestamp | Unix epoch seconds when we signed the request |
X-Edgar-Test | Set to true only for events sent from the "Send test event" button. Absent on real deliveries. |
Verifying signatures#
Compute HMAC-SHA256 of the raw request body with your signing secret and compare to the value(s) in X-Edgar-Signature. Use a constant-time comparison.
Node.js#
import crypto from 'node:crypto';
export function verifyEdgarSignature(rawBody, header, secret) {
if (!header) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// header may be 'sha256=A' or 'sha256=A,sha256=B' during rotation.
return header.split(',').some((sig) => {
const [scheme, hex] = sig.split('=');
if (scheme !== 'sha256' || !hex) return false;
const a = Buffer.from(hex, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}Python#
import hmac, hashlib
def verify_edgar_signature(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
for sig in header.split(','):
scheme, _, hex_sig = sig.partition('=')
if scheme != 'sha256' or not hex_sig:
continue
if hmac.compare_digest(hex_sig, expected):
return True
return FalseGo#
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func VerifyEdgarSignature(rawBody []byte, header, secret string) bool {
if header == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, sig := range strings.Split(header, ",") {
parts := strings.SplitN(sig, "=", 2)
if len(parts) != 2 || parts[0] != "sha256" {
continue
}
got, err := hex.DecodeString(parts[1])
if err != nil {
continue
}
if hmac.Equal(got, expected) {
return true
}
}
return false
}Preventing replay — X-Edgar-Signature-V2#
X-Edgar-Signature covers only the body, so a captured (body, signature) pair stays valid forever — an attacker who records one delivery can resend it at any time. X-Edgar-Signature-V2 closes this: it signs <X-Edgar-Timestamp>.<raw body> with the same secret (and the same comma-joined dual-signature during a rotation window), so the signature is bound to the moment we sent it.
To use it, verify the signature over the timestamped payload and reject deliveries whose timestamp is too old. A 5-minute tolerance is a good default — wide enough for clock skew and delivery latency, narrow enough that a replayed capture is rejected.
import crypto from 'node:crypto';
export function verifyEdgarSignatureV2(rawBody, timestamp, header, secret, toleranceSeconds = 300) {
if (!header || !timestamp) return false;
// Reject stale (or future-dated) deliveries before checking the signature.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// header may be 'sha256=A' or 'sha256=A,sha256=B' during rotation.
return header.split(',').some((sig) => {
const [scheme, hex] = sig.split('=');
if (scheme !== 'sha256' || !hex) return false;
const a = Buffer.from(hex, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}X-Edgar-Signature is unchanged and still sent, so existing receivers keep working — but new integrations should verify X-Edgar-Signature-V2 and enforce the timestamp check.
Payload — filing.analyzed.v1#
The wire-shape we lock with a version tag. Future event types add new strings (e.g. webhook.test.v1); receivers branch on type first.
{
"id": "evt_a1b2c3d4e5f6",
"type": "filing.analyzed.v1",
"workflow_type": "material_event_analysis",
"timestamp": "2026-04-29T13:30:42.000Z",
"filing": {
"accession": "0000320193-26-000042",
"ticker": "AAPL",
"company": "APPLE INC",
"form": "8-K",
"filed_at": "2026-04-29T13:30:00-04:00"
},
"context_prompt": "...",
"analysis": {
"summary": "...",
"signals": [],
"confidence": 0.92,
"model": "llama-3.3-70b",
"latency_ms": 1500
}
}workflow_type carries the granular kind of analysis (material_event_analysis, earnings_event, insider_trade_signal, etc.) — branch on it for sub-classification.
Payload — webhook.test.v1#
Sent only when you click "Send test event" on an endpoint. Body includes test: true and a title / message so a naive Slack template still reads as a test:
{
"id": "evt_test_abc123def456",
"type": "webhook.test.v1",
"test": true,
"timestamp": "2026-04-29T13:30:42.000Z",
"title": "Test webhook from edgar.tools",
"message": "This is a test event triggered from your webhook settings...",
"filing": {
"accession": "0000000000-00-000000",
"ticker": "TEST",
"company": "TEST COMPANY (webhook test event)",
"form": "TEST",
"filed_at": "2026-04-29T13:30:42.000Z"
}
}Retries#
We retry on:
- HTTP 5xx
- HTTP 429 (rate-limited)
- Network errors / timeouts
We do not retry on:
- HTTP 2xx (delivered)
- HTTP 4xx other than 429 (your service rejected it — fix the receiver, the next match will go through)
- HTTP 3xx redirects (we don't follow; configure a final URL)
Up to 3 attempts with exponential backoff. After 3 failures the message goes to the dead-letter queue. Sustained failures across many events disable the endpoint automatically.
Idempotency#
X-Edgar-Delivery is unique per event. Use it as your dedupe key — a retry of the same event has the same delivery id. We deliver each event at-least-once.
Secret rotation#
Rotate from Settings → Webhooks. Rotation:
- Mints a new secret (shown once).
- Keeps the old secret valid for 24 hours.
- During the window, every delivery's
X-Edgar-Signaturecarries both signatures, comma-joined:sha256=<new>,sha256=<old>.
Update your stored secret to the new value any time during the window. The verification snippets above already accept either match.
Verifying your endpoint#
- Create an endpoint at Settings → Webhooks. Save the secret.
- Click the bolt icon (Send test event).
- Confirm the response panel shows HTTP 200, your latency, and any body excerpt your service returned.
- In your service logs, verify
X-Edgar-Test: trueand that signature verification succeeded.
If verification fails, the most common cause is reading the request body twice (e.g., as JSON before HMAC). HMAC must be over the raw bytes, exactly as received.
Recent deliveries#
The Deliveries page lists every attempt for the past 30 days — HTTP status, latency, response excerpt, and which sentinel fired. Filter to failures to triage broken endpoints.

Tier gating#
Webhooks — both configuring an endpoint and receiving deliveries — require Analyst tier or above (see Compare Plans).