> ## 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.

# How to Set Up Webhooks

> Receive real-time market events at your own endpoint. Step-by-step with a working Python receiver and signature verification.

This guide walks you through receiving your first webhook delivery end-to-end: create a destination in the dashboard, spin up a receiver, verify the signature, and replay a delivery. By the end you'll have a production-ready handler.

<Info>
  **Already integrated?** Jump to the [Webhooks reference](/webhooks) for the full event catalog, payload envelope, retry schedule, and production checklist.
</Info>

## Prerequisites

* **Pro or Enterprise plan.** [Upgrade if needed](https://financialdatasets.ai/billing/subscriptions).
* **Python 3.10+** for the code samples (Node alternative shown in tabs).
* **An HTTPS endpoint you control.** For local testing, use a tunnel like [ngrok](https://ngrok.com) or [localtunnel](https://theboroer.github.io/localtunnel-www/) to expose `localhost`.

## What we'll build

Throughout this guide we'll build a handler for a fictional integrator, **Acme Capital**. Acme wants a Slack ping the moment we publish new earnings for any ticker on their watchlist (`AAPL`, `MSFT`, `NVDA`).

|                                  | Value                                                  |
| -------------------------------- | ------------------------------------------------------ |
| **Acme's endpoint URL**          | `https://acme.example.com/webhooks/financial-datasets` |
| **Signing secret (placeholder)** | `whsec_PLACEHOLDER_FROM_DASHBOARD`                     |
| **Event type**                   | `earnings.created`                                     |
| **Watchlist**                    | `AAPL`, `MSFT`, `NVDA`                                 |

Substitute your own values as you follow along.

## Step 1: Add a destination in the dashboard

1. Go to the [Webhooks](https://financialdatasets.ai/webhooks) page in your dashboard.
2. Click **Add destination**.
3. Paste your HTTPS endpoint URL — for Acme that's `https://acme.example.com/webhooks/financial-datasets`.
4. Expand the **Earnings** group, check `earnings.created`, click **Add destination**.
5. The new row appears with a truncated signing secret (`whsec_...`). Click the row to expand it, then click **Reveal** next to the signing secret and copy the full value. You'll plug it into the receiver in the next step.

<Note>
  Treat your signing secret like a credential. Store it in a secret manager (not git, not your `.env` checked into source control), and rotate it from the dashboard if it ever leaks.
</Note>

## Step 2: Spin up a receiver

The receiver does three things: read the raw request body, verify the `FD-Signature` header, and return `2xx`.

<Tabs>
  <Tab title="Python (Flask)">
    Install Flask:

    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    pip install flask
    ```

    Save as `receiver.py`:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import hmac
    import hashlib
    import time
    from flask import Flask, request, abort

    app = Flask(__name__)

    # From the dashboard — Acme's destination signing secret.
    SIGNING_SECRET = "whsec_PLACEHOLDER_FROM_DASHBOARD"
    SIGNATURE_MAX_SKEW_SECONDS = 300

    # Acme's earnings watchlist. Replace with your own.
    WATCHLIST = {"AAPL", "MSFT", "NVDA"}


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

        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)


    def handle_earnings_created(event: dict) -> None:
        """Acme's business logic — ping Slack for watchlisted tickers."""
        obj = event["data"]["object"]
        ticker = obj.get("ticker")
        if ticker in WATCHLIST:
            # Replace this with your Slack / pager / queue integration.
            print(f"🔔 watchlist hit: {ticker} reported earnings for "
                  f"{obj.get('fiscal_period')}")
        else:
            print(f"   skip {ticker} (not on watchlist)")


    @app.post("/webhooks/financial-datasets")
    def receive():
        # IMPORTANT: read raw bytes BEFORE Flask parses to JSON.
        # The signature is computed over the exact bytes we sent.
        raw = request.get_data()
        sig = request.headers.get("FD-Signature", "")

        if not verify(raw, sig, SIGNING_SECRET):
            abort(400, "invalid signature")

        event = request.get_json()
        print(f"received {event['type']} id={event['id']} livemode={event['livemode']}")

        # TODO: in production, dedupe on event["id"] before doing side effects
        # and enqueue heavy work to a background job so this handler returns fast.
        if event["type"] == "earnings.created":
            handle_earnings_created(event)

        return "", 200


    if __name__ == "__main__":
        app.run(port=5001)
    ```

    Run it:

    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    python receiver.py
    ```

    Then expose it via a tunnel for the dashboard to reach:

    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    ngrok http 5001
    # → copy the https://...ngrok-free.app URL into the destination's URL field
    ```
  </Tab>

  <Tab title="Node (Express)">
    Install Express:

    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    npm install express
    ```

    Save as `receiver.js`:

    ```js theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import crypto from 'node:crypto'
    import express from 'express'

    // From the dashboard — Acme's destination signing secret.
    const SIGNING_SECRET = 'whsec_PLACEHOLDER_FROM_DASHBOARD'
    const SIGNATURE_MAX_SKEW_SECONDS = 300

    // Acme's earnings watchlist. Replace with your own.
    const WATCHLIST = new Set(['AAPL', 'MSFT', 'NVDA'])

    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'),
        rawBody,
      ])
      const expected = crypto
        .createHmac('sha256', secret)
        .update(signingInput)
        .digest('hex')
      const a = Buffer.from(candidate, 'hex')
      const b = Buffer.from(expected, 'hex')
      return a.length === b.length && crypto.timingSafeEqual(a, b)
    }

    // Acme's business logic — ping Slack for watchlisted tickers.
    function handleEarningsCreated(event) {
      const obj = event.data.object
      const ticker = obj.ticker
      if (WATCHLIST.has(ticker)) {
        // Replace this with your Slack / pager / queue integration.
        console.log(
          `🔔 watchlist hit: ${ticker} reported earnings for ${obj.fiscal_period}`,
        )
      } else {
        console.log(`   skip ${ticker} (not on watchlist)`)
      }
    }

    const app = express()

    // IMPORTANT: raw() must run BEFORE any JSON parser on this route,
    // so req.body is the exact bytes we sent.
    app.post(
      '/webhooks/financial-datasets',
      express.raw({ type: 'application/json' }),
      (req, res) => {
        const sig = req.header('FD-Signature') ?? ''
        if (!verify(req.body, sig, SIGNING_SECRET)) {
          return res.status(400).send('invalid signature')
        }
        const event = JSON.parse(req.body.toString('utf8'))
        console.log(`received ${event.type} id=${event.id} livemode=${event.livemode}`)
        // TODO: in production, dedupe on event.id before side effects
        // and enqueue heavy work to a background job so this handler returns fast.
        if (event.type === 'earnings.created') {
          handleEarningsCreated(event)
        }
        res.status(200).end()
      },
    )

    app.listen(5001, () => console.log('listening on :5001'))
    ```

    Run + tunnel:

    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    node receiver.js
    ngrok http 5001
    ```
  </Tab>
</Tabs>

## Step 3: Fire a test event

Back in the dashboard:

1. Find your destination row and click it to expand.
2. Click **Send test event**.
3. Within \~5 seconds your receiver should log two lines and the event should appear in **Recent events** with a green checkmark and HTTP `200`.

Acme's receiver should log something like:

```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
received earnings.created id=b5fd9c10-e3b2-4c5d-a2b3-98fda43365cf livemode=False
   skip BLDR (not on watchlist)
```

The canned test event uses ticker `BLDR`, which isn't on Acme's watchlist — so it goes through the verify-and-skip branch. Once real `earnings.created` events for `AAPL`, `MSFT`, or `NVDA` arrive, the handler will hit the `🔔 watchlist hit` branch instead.

Test events have `livemode: false` and use a canned `earnings.created` payload — useful for confirming the wiring before real data flows.

<Warning>
  Test events are throttled to **1 per minute per destination**. A second click within 60 seconds will return `429 Too Many Requests`.
</Warning>

## Step 4: Inspect a delivery

Click any row in **Recent events** to slide up the detail drawer. You'll see:

* **Request**: event type, event id, endpoint URL, attempt number, timestamps.
* **Response**: HTTP status, duration, body returned by your endpoint.
* **Signed payload**: the exact JSON we POSTed, ready to copy or download as `.json` for replaying in your local tests.

If verification fails or your endpoint 5xx's, this drawer is where you'll diagnose it. The response body field will show whatever your server returned.

## Step 5: Replay a delivery

From the detail drawer, click **Replay delivery**. We'll re-send the same event payload through the full delivery pipeline (signed with your *current* secret) and create a new row in **Recent events** with attempt number 1. The original row stays as a historical record.

This is the fastest way to debug a handler fix — patch your code, deploy, hit Replay.

## Production checklist

Before flipping real customers onto your handler:

* **HTTPS endpoint.** Required in production; we reject `http://` URLs.
* **Return 2xx within 10s.** Anything else counts as a failure. If real processing takes longer, enqueue to a background job and return `200` immediately.
* **Dedupe on `event.id`.** Retries and replays mean the same id can arrive multiple times. A unique constraint on a `processed_events` table is the simplest reliable pattern.
* **Watch the auto-disable threshold.** After 50 consecutive failed deliveries we automatically disable the destination to protect your endpoint. Catch issues earlier by monitoring the `Recent failures` field on the destination row.
* **Rotate the signing secret if it leaks.** Click **Regenerate secret** from the destination's expanded row. The old secret stops working immediately, so deploy your config change in lockstep.

## Common pitfalls

**Signature mismatch on every request.** You're hashing the JSON-parsed body instead of the raw bytes. Read the raw request body before any framework middleware parses it (see the `request.get_data()` / `express.raw()` calls in the samples above).

**The 200 comes from your auth middleware, not your handler.** If a reverse proxy or auth layer swallows the request and replies with its own 200, our worker sees success but your handler never runs. Check your access logs.

**Timeouts on big handlers.** Don't process the entire downstream pipeline inside the webhook handler. Enqueue and acknowledge.

**Clock drift.** Our verification rejects timestamps more than 5 minutes off. If you keep failing right after a deploy, check NTP sync on your server.

## Next steps

* [Webhooks reference](/webhooks) — full event catalog, payload envelope, retry semantics, and security model.
* Join the [Discord](https://discord.gg/hTtb8wzgSQ) if you hit something not covered here.
