Webhooks — events, signatures, Slack and Teams
Webhooks deliver workspace events to your endpoint as JSON POSTs. They are an Enterprise and Government feature, configured at /settings/webhooks. Slack, Microsoft Teams, and Discord endpoints are auto-detected and rendered with native message cards.
Event types
| Event | When it fires |
|---|---|
brief.generated | A daily Brief completes synthesis and validation |
brief.retracted | A Brief paragraph is retracted via the moderation queue |
forecast.prediction_created | A new Forecast prediction crosses the publish threshold |
watch.alert_fired | A watched agency, vendor, or keyword triggers an alert |
saved_search.alert_fired | A saved-search delta crosses the per-search threshold |
case.evidence_added | Evidence is pinned to a case |
webhook.test | You pressed Send test — synthetic, never fired by the platform |
This table is the whole list. tests/lib/webhooks-help-doc.test.ts fails the build if it disagrees with WEBHOOK_EVENT_TYPES in lib/webhooks/types.ts, so an event you cannot find here is an event FedGrade does not send.
Payload envelope
Every delivery body is this envelope:
{
"event_type": "brief.generated",
"event_id": "b7c1e0a4-9f3d-4d2a-9a1e-1f2c3d4e5f60",
"occurred_at": "2026-09-16T11:04:07.913Z",
"workspace_id": "3f8b2a10-5c44-4e91-8b77-0d1a2b3c4d5e",
"data": { }
}
| Field | Type | Meaning |
|---|---|---|
event_type | string | One of the event types above. |
event_id | string | The source-event id (brief id, prediction id, …). Use this for idempotency — see Delivery semantics. |
occurred_at | string | ISO-8601 UTC timestamp of when the event happened — not when it was delivered. A retry keeps the original occurred_at. |
workspace_id | string | The workspace the event belongs to, so one endpoint can serve several tenants. |
data | object | Event-specific body; the shape varies by event_type. |
There are no id, event, or created_at fields. (Before 2026-09-16 this page named those three; nothing ever sent them, so a receiver written against the old page read undefined for the event type and its own idempotency key. If your receiver reads payload.id or payload.event, that is the bug.)
This page is the reference for the envelope; the OpenAPI document at /api/v1/openapi.json covers the REST API and does not include a webhook schema.
Slack, Teams, and Discord endpoints receive that platform's native message object instead of this envelope — the envelope fields still travel as the X-FedGrade-* headers below.
Delivery headers
| Header | Value |
|---|---|
X-FedGrade-Timestamp | Send time, integer Unix seconds (e.g. 1789564800). |
X-FedGrade-Signature | One or more v1=<hex> entries, comma-separated. |
X-FedGrade-Event | The event_type. |
X-FedGrade-Event-ID | The event_id — the idempotency key. |
X-FedGrade-Delivery-ID | This attempt's delivery-row id. Changes on every retry; X-FedGrade-Event-ID does not. |
Signature verification
X-FedGrade-Signature is v1=<hex>, where <hex> is the lowercase HMAC-SHA256, keyed with your endpoint's signing secret, over the timestamp, a literal ., and the raw request body exactly as received.
Verify before processing:
ts = header["X-FedGrade-Timestamp"]
expected = hmac_sha256(signing_secret, ts + "." + raw_body) # hex
entries = [e.strip() for e in header["X-FedGrade-Signature"].split(",")]
ok = any(constant_time_equal("v1=" + expected, e) for e in entries)
and abs(now_unix_seconds - int(ts)) <= 300
Reject any request where no entry matches. Reject any request whose timestamp is more than 5 minutes (300 seconds) from your server clock — because the timestamp is inside the signed string, a captured delivery cannot be replayed later with a fresh timestamp. Compute the HMAC over the raw bytes you received, not over a re-serialised copy: for a Slack, Teams, or Discord endpoint the signed bytes are the translated message body that was actually sent, not the FedGrade envelope.
Rotating the signing secret
Click Rotate on the endpoint at /settings/webhooks. For 24 hours after a rotation each delivery carries two v1= entries — the new secret first, the previous one second — so an endpoint that has not yet been re-configured keeps verifying, and a retry that was queued before the rotation still verifies. Accept the request if any entry matches; that is why the check above iterates. After the grace the previous secret is dropped and only one entry is sent. Rotating twice inside the grace keeps only the most recent previous secret. The rotate response returns previous_secret_valid_until so you know when the second entry disappears.
Slack, Teams, Discord auto-rendering
If your endpoint hostname matches hooks.slack.com, outlook.office.com/webhook/, or discord.com/api/webhooks/, the delivery is reformatted into the native message format for that platform:
- Slack — Block Kit with a header, paragraph block, and "View in FedGrade" action button
- Teams — Adaptive Card with the same layout
- Discord — Embed with title, description, and inline-field metadata
No transformation is applied to other hostnames — your endpoint receives the raw JSON envelope.
Delivery semantics
Delivery is at-least-once. Your endpoint may see the same event_id more than once: a 2xx that was lost on the wire, a retry sweep whose lease lapsed mid-send, or a manual replay all re-send the same event. De-duplicate on event_id / X-FedGrade-Event-ID and make your handler idempotent.
Ordering is not guaranteed. Events are fanned out concurrently and retried on independent schedules, so a retried event can arrive after a newer one. Order by occurred_at if order matters to you.
Retries
Failed deliveries (non-2xx response, or timeout past 10s) are retried on a fixed schedule. The delay after attempt n is:
| After attempt | Delay |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 and later | 3 hours |
A delivery is attempted at most 10 times; after the tenth it is dead-lettered. That puts roughly 16 hours between the first attempt and the last, so an endpoint that is down for a working day still receives every event once it comes back. A retry sweep runs every 5 minutes and picks up deliveries whose next_retry_at has elapsed, so the observed gap is the scheduled delay rounded up to the next sweep.
(Before 2026-09-16 this page described 2ⁿ seconds capped at one hour with 5 attempts. The cap never bound: five attempts of 2ⁿ gave gaps of 2s, 4s, 8s and 16s, so the real window was under 31 seconds of backoff and the whole thing was over inside one sweep tick. A two-hour outage lost every event.)
Separately, an endpoint is auto-disabled after 10 consecutive failed deliveries (a running counter — there is no one-hour window). You are notified in-app at the 5th consecutive failure and again when the endpoint is disabled. Any 2xx resets the counter to zero. Disabled endpoints are flagged at /settings/webhooks with the reason recorded.
Testing
Each endpoint has a Send test button that delivers a synthetic webhook.test payload with data.test == true. Use this for development setup and to verify signature handling without waiting for a real Brief.
Audit
Every webhook delivery attempt (success or failure) is logged. Click Deliveries on the endpoint at /settings/webhooks to see the last 100 attempts with the event type, attempt number, status, timestamp, response code and the first 1 KB of the response body; failed and dead-lettered deliveries have a Replay button. Deliveries are retained for 30 days; Government tier extends to 7 years. The daily retention sweep enforces those two numbers; nothing is kept past them.
Last updated 2026-09-21.