> ## Documentation Index
> Fetch the complete documentation index at: https://docs.financialdatasets.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Push notifications for real-time market events. Scale and Enterprise.

## Overview

Instead of polling our API for new data, you can register an HTTPS endpoint and we'll POST to it the moment an event fires. Use webhooks to wake up an agent on a fresh earnings release, new financial statements, extracted KPIs and guidance, insider filings, or a new macro print; push records into your warehouse; or trigger downstream automation.

<Info>
  **New to webhooks?** The [setup guide](/guides/setup-webhooks) walks you through a working Python receiver end-to-end in under 15 minutes. This page is the reference you'll come back to once you're integrated.
</Info>

<Note>
  Webhooks are available on **Scale** and **Enterprise** plans. [Manage your plan](https://financialdatasets.ai/billing/subscriptions) from the dashboard.
</Note>

## Get started

Five steps to get from zero to receiving events:

<Steps>
  <Step title="Create a destination">
    Register an HTTPS endpoint and the events you want to receive from the [Webhooks dashboard](https://financialdatasets.ai/webhooks).
  </Step>

  <Step title="Set up your endpoint">
    Stand up an HTTPS handler that can accept JSON POSTs and read the raw request body. See the [setup guide](/guides/setup-webhooks) for Python + Node examples.
  </Step>

  <Step title="Verify the signature">
    Confirm each request came from us using the `FD-Signature` header. See [Verifying the signature](#verifying-the-signature).
  </Step>

  <Step title="Test with a canned event">
    Fire a test event from the dashboard's destination row and confirm your endpoint returns `2xx` within 10 seconds.
  </Step>

  <Step title="Go live">
    Walk through the [production checklist](#production-checklist) before flipping real customer flows onto your handler.
  </Step>
</Steps>

## Production checklist

Before relying on webhooks for production-critical flows, work through each of these:

1. **Use HTTPS, not HTTP.** Your endpoint URL must start with `https://`. We won't deliver to plain `http://` in production. [Security →](#security)
2. **Confirm each request actually came from us.** Use the `FD-Signature` header to check authenticity. Skip this and anyone could forge events. [How to verify →](#verifying-the-signature)
3. **Reply within 10 seconds.** Send back `200 OK` quickly, then do the heavy work in the background. Slow replies count as failures and we'll retry. [Delivery behavior →](#delivery-behavior)
4. **Return `2xx` for event types you don't handle.** A handler that throws on an unrecognized type turns our delivery into a failure. Acknowledge first, then decide what to process. [Troubleshooting →](#troubleshooting)
5. **Don't process the same event twice.** We may deliver the same event more than once. Track which `event.id` values you've handled and skip duplicates. [Idempotency →](#idempotency)
6. **Keep your signing secret safe.** Treat it like a password: never commit it to git, store it in a secret manager, and rotate it from the dashboard if it ever leaks. [Security →](#security)

## Event types

We publish 16 event types across seven families. A destination can subscribe to any combination.

| Family                    | Events                                                                                                             | When they arrive                                                                                              |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| **Earnings**              | `earnings.created`                                                                                                 | Within minutes of the filing hitting EDGAR.                                                                   |
| **Financial statements**  | `income_statements.created`, `balance_sheets.created`, `cash_flow_statements.created`, `financial_metrics.created` | Within minutes of a 10-K, 10-Q, 20-F, or 6-K being processed.                                                 |
| **Segmented financials**  | `income_statement_segments.created`, `balance_sheet_segments.created`, `cash_flow_statement_segments.created`      | Same filings, only when the filing reports segment breakdowns.                                                |
| **Earnings intelligence** | `operating_kpis.created`, `forward_guidance.created`, `non_gaap_metrics.created`                                   | Minutes after `earnings.created`, once analysis completes.                                                    |
| **Insider activity**      | `insider_trades.created`, `insider_ownership.created`                                                              | Once daily, early morning UTC, covering the previous day's filings.                                           |
| **Filings**               | `filing_items.metadata.created`                                                                                    | Within minutes of a 10-K, 10-Q, or 8-K being processed. Metadata only, with a url to fetch the filing text.   |
| **Macroeconomics**        | `yield_curve.created`, `inflation.created`                                                                         | The evening a new Treasury yield curve day is published; within hours of each monthly consumer price release. |

<Card title="Webhook event types" icon="list" href="/webhooks/events">
  Full catalog: the payload each event carries, its field schema, and the API endpoint its entries match.
</Card>

## Payload format

Every delivery is a `POST` of a JSON envelope wrapping a resource. The envelope is identical across event types; `data.object` carries the resource, and its shape depends on the event `type`.

```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "id": "04b97437-62cd-4ccb-b7eb-54765dbaa72d",
  "type": "earnings.created",
  "api_version": "2026-05-20",
  "livemode": true,
  "created": 1779309269,
  "data": {
    "object": { "...": "shape depends on type" }
  }
}
```

Array entries inside `data.object` are identical to entries from the corresponding API response, so any parser you've written against our REST endpoints works unchanged. See [Webhook event types](/webhooks/events) for the per-event schema.

## Headers

Every request includes:

| Header         | Value                              |
| -------------- | ---------------------------------- |
| `Content-Type` | `application/json`                 |
| `User-Agent`   | `FinancialDatasets-Webhook/1.0`    |
| `FD-Signature` | `t=<unix-ts>,v1=<hex-hmac-sha256>` |

## Verifying the signature

The `FD-Signature` header lets you confirm the request actually came from us and wasn't tampered with. The signature is an HMAC-SHA256 of `{timestamp}.{raw_body}` using your destination's signing secret.

<Warning>
  Always verify against the **raw request bytes**. If you parse the body to JSON and re-serialize before hashing, the bytes won't match and verification will fail.
</Warning>

<Tabs>
  <Tab title="Python">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import hmac
    import hashlib
    import time

    SIGNATURE_MAX_SKEW_SECONDS = 300  # 5 minutes

    def verify(raw_body: bytes, header_value: str, secret: str) -> bool:
        try:
            parts = dict(piece.split("=", 1) for piece in header_value.split(","))
            ts = int(parts["t"])
            candidate = parts["v1"]
        except (KeyError, ValueError):
            return False

        # Reject anything outside the skew window — defeats replay attacks
        # if your signing secret ever leaks.
        if abs(int(time.time()) - ts) > SIGNATURE_MAX_SKEW_SECONDS:
            return False

        signing_input = f"{ts}.".encode("utf-8") + raw_body
        expected = hmac.new(
            secret.encode("utf-8"), signing_input, hashlib.sha256,
        ).hexdigest()
        return hmac.compare_digest(candidate, expected)
    ```
  </Tab>

  <Tab title="Node">
    ```js theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import crypto from 'node:crypto'

    const SIGNATURE_MAX_SKEW_SECONDS = 300 // 5 minutes

    export function verify(rawBody, headerValue, secret) {
      let ts, candidate
      try {
        const parts = Object.fromEntries(
          headerValue.split(',').map((p) => p.split('=', 2)),
        )
        ts = parseInt(parts.t, 10)
        candidate = parts.v1
      } catch {
        return false
      }

      if (Math.abs(Math.floor(Date.now() / 1000) - ts) > SIGNATURE_MAX_SKEW_SECONDS) {
        return false
      }

      const signingInput = Buffer.concat([
        Buffer.from(`${ts}.`, 'utf8'),
        Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'),
      ])
      const expected = crypto
        .createHmac('sha256', secret)
        .update(signingInput)
        .digest('hex')

      // Lengths must match before timingSafeEqual; otherwise it throws.
      const a = Buffer.from(candidate, 'hex')
      const b = Buffer.from(expected, 'hex')
      return a.length === b.length && crypto.timingSafeEqual(a, b)
    }
    ```
  </Tab>
</Tabs>

Use a constant-time comparison (`hmac.compare_digest` / `crypto.timingSafeEqual`). A normal `==` is vulnerable to timing attacks.

## Delivery behavior

A delivery succeeds when your endpoint returns any `2xx` response within 10 seconds. Anything else — a `4xx`, a `5xx`, a connection error, or a timeout — counts as a failure, and we'll retry on this schedule:

| Attempt | When we try it                                     |
| ------- | -------------------------------------------------- |
| 1       | Immediately, as soon as the event fires.           |
| 2       | About 1 minute after attempt 1 (±10s of jitter).   |
| 3       | About 10 minutes after attempt 2 (±60s of jitter). |

After three failed attempts we stop trying that specific event. The destination stays active and keeps receiving new events normally; you'll just see this delivery marked `Dead` in the dashboard.

**Ordering is not guaranteed.** Events for the same resource can interleave, and a retry that succeeds late can land after a newer event. Don't build logic that assumes arrival order.

<Warning>
  **Auto-disable safety net.** If a destination piles up **50 consecutive failed deliveries**, we automatically disable it so we don't keep pounding on a broken endpoint. You'll see `Auto-disabled` on the row with a short reason. Once you've fixed the issue, re-enable it from the row's expanded view.

  We email the account owner along the way: a heads-up at **25 consecutive failures**, and again if the destination is disabled at 50. A single successful delivery resets the counter and re-arms the warning.
</Warning>

### Idempotency

The same `event.id` can arrive more than once: replays, retries that succeed late, network races. Your handler must be idempotent.

* Dedupe on `id` (e.g. insert into a `processed_events` table with a unique constraint and ignore conflicts).
* Don't trust delivery order; events for the same resource can interleave.

## Test mode

The **Send test event** button on each destination row fires a canned event with `livemode: false`, shaped exactly like a real event: an earnings destination gets an `earnings.created` test, an insider destination gets an insider-shaped test. If your handler parses the test event, it parses real events.

A destination subscribed to several event types gets a test of the first type it subscribes to. To exercise a specific one, replay a real delivery of that type from **Recent events** instead.

Test events are delivered only to the destination you fired them from, and are throttled to **1 per minute per destination**.

## Security

* **HTTPS only.** We refuse to deliver to plain `http://` URLs in production.
* **SSRF defense.** We resolve your URL's IP at every delivery attempt and reject private (RFC 1918), loopback, link-local, and metadata-service ranges.
* **Signing-secret rotation.** Use **Regenerate secret** on the destination's row whenever you suspect a leak. The old secret stops working as soon as you rotate, so deploy the new secret to your handler in lockstep.
* **Secret access.** Re-copy your secret from the destination's row in the dashboard if you misplace it. Retrieval is gated by your dashboard login; API keys cannot read signing secrets.

## Limits

| Limit                                    | Value                                                 |
| ---------------------------------------- | ----------------------------------------------------- |
| Active destinations per account          | 5. Disable or delete unused ones to free up slots.    |
| Event types per destination              | Any combination; one endpoint can receive everything. |
| Delivery attempts per event              | 3, then the delivery is marked `Dead`.                |
| Response deadline                        | 10 seconds.                                           |
| Consecutive failures before auto-disable | 50 (warning email at 25).                             |
| Test events                              | 1 per minute per destination.                         |
| Signature skew window                    | 5 minutes.                                            |

## Troubleshooting

**Destination keeps auto-disabling.** Open the destination's expanded row and check `Recent failures`. Your endpoint is probably 5xx-ing or timing out beyond 10s. The fastest debug path is replaying a recent failed delivery from **Recent events** and inspecting the response body that came back.

**Some event types fail while others succeed.** Open **Recent events** and compare the `Event` and `Response` columns: if one family of events fails while the rest deliver fine, the problem is in your handler's branch for those types, not the connection. Your server logs will have the stack trace, and replaying one of the failed deliveries from **Recent events** reproduces the exact payload on demand. A destination in this state never auto-disables, because the successful deliveries keep resetting the consecutive-failure counter, so it can stay half-broken indefinitely. If you don't need those event types, edit the destination and unsubscribe from them.

**Signature verification fails.** Three usual suspects:

1. You parsed the body before computing the HMAC. Always verify the raw bytes.
2. The signing secret in your config is stale after a rotation. Re-copy from the dashboard.
3. Your server has clock drift > 5 minutes. We reject signatures outside the skew window.

**No events arrive at all.** Confirm the destination is `Active` (not Disabled). Fire a test event from the row's **⋯** menu; if that doesn't arrive within \~5 seconds, the destination URL is unreachable from our network (check firewalls / IP allowlists).

Still stuck? Open a ticket via [Support](/support) and include the destination id + a recent event id from the dashboard.

## See also

* [Webhook event types](/webhooks/events) — the full catalog with payload schemas.
* [How to set up webhooks](/guides/setup-webhooks) — step-by-step walkthrough with a working Python receiver.
* [Open the Webhooks dashboard](https://financialdatasets.ai/webhooks) to create your first destination.
