> SpecStream is the live web data engine for teams and agents. This is the Markdown copy of https://specstream.ai/docs/webhooks. Every page, with what SpecStream does and costs: https://specstream.ai/llms.txt

# Webhooks

A webhook posts a signed message to your own endpoint when a find finishes, a monitor finds something new, or records are added to a collection. Each message carries the records, every value with its source URLs and when it was read.

## Events

Choose any of these when you create a webhook.

| Event | Sent when | Records in the message |
| --- | --- | --- |
| `find.completed` | A find finishes. | Every result of the find. |
| `monitor.found` | A [scheduled run](https://specstream.ai/docs/schedules.md) finds something new or changed. | Only the new and changed records. |
| `collection.records_added` | Records are added to a collection by a scheduled run, a refresh, or an import. | Only the added records. |

Webhooks cost nothing and work on every plan. The work behind the records is paid from your balance like any other, inside the limits you set.

## Create a webhook

1.  1

    ## 1. Add an endpoint

    In the workspace, open **Agents & API**, choose **Webhooks**, then **New webhook**. Enter your endpoint's URL and choose the events. The URL has to be public HTTPS on port 443, with no username or password in it. SpecStream doesn't follow redirects.

2.  2

    ## 2. Copy the secret

    The signing secret is shown once, right after you create the webhook. Store it where your endpoint can read it, for example in an environment variable named `SPECSTREAM_WEBHOOK_SECRET`. SpecStream can't show it again.

3.  3

    ## 3. Answer with a 2xx

    Your endpoint has 10 seconds to answer with a 2xx status. Any other answer, or none, counts as a failed attempt.

You can also create one with the API. The response carries the secret, once.

Terminal

```bash
curl https://api.specstream.ai/v1/account/webhooks \
  -H "Authorization: Bearer $SPECSTREAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/specstream",
    "events": ["find.completed", "monitor.found"]
  }'
```

JSON

```json
{
  "data": {
    "hook_id": "hook_...",
    "event_name": "find.completed",
    "events": ["find.completed", "monitor.found"],
    "secret": "..."
  }
}
```

Without `events`, a webhook gets `find.completed`. List your webhooks with `GET /v1/account/webhooks`, which never returns a secret, and revoke one with `DELETE /v1/account/webhooks/{id}`. Revoking cancels every delivery still waiting.

A monitor can call a webhook of its own. Turn on **Call a webhook** in the monitor's settings. It gets its own signing secret, shown once after you save, and receives `monitor.found` for that monitor.

## What a delivery looks like

Every delivery is a POST with a JSON body and three headers.

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `Specstream-Event-Id` | The id of this delivery. A retry carries the same id. A delivery in parts numbers each one: `<id>.1`, `<id>.2`. |
| `Specstream-Signature` | `t=<unix seconds>,v1=<hex signature>` |

The body has these fields.

-   `id` and `event`: the delivery and the event that caused it.
-   `find_id`: the find the records came from. Open it at `https://specstream.ai/workspace/collections/<find_id>`.
-   `commerce`: the price the run was approved at and what it was charged.
-   `records`: each record with its `entity_id` and its `cells`. A cell has a `label`, a `value`, and a `provenance` with the `source_url` and `observed_at` of the page the value was read from.
-   `flat_rows`: the same records as flat, named fields for tools that map fields one to one. `name`, `website`, `email`, `phone`, and `address` use one name whatever the kind of record. Each value's page is beside it as `<field>_source`, for example `phone_source`. `source_url` and `checked_at` are the page of the fact that put the record in the list, such as a Form D filing, and when SpecStream read it. `list_link` opens the list in the workspace.
-   `part` and `parts`, only when one message would pass 90 KB. The records then come in several messages of 90 KB or less, so tools with a size limit, such as Clay, take every one. `part` numbers this message out of `parts`, and each part carries all the other fields.
-   For `monitor.found` and `collection.records_added`: `new_ids`, `changed_ids`, `unresolved_count` (records the run couldn't read this time), `previous_find_id`, and `schedule_id`.

A find.completed delivery, trimmed

```json
{
  "id": "dlv_...",
  "event": "find.completed",
  "find_id": "fnd_9Hw3vLpA",
  "records": [
    {
      "entity_id": "ent_...",
      "ordinal": 0,
      "cells": [
        {
          "label": "Name",
          "value": "Harbor Street Dental",
          "provenance": {
            "source_url": "https://harborstreetdental.example/about",
            "observed_at": "2026-10-19T14:02:11Z"
          }
        }
      ]
    }
  ],
  "flat_rows": [
    {
      "id": "fnd_9Hw3vLpA:ent_...",
      "list_id": "fnd_9Hw3vLpA",
      "list_link": "https://specstream.ai/workspace/collections/fnd_9Hw3vLpA",
      "position": 0,
      "name": "Harbor Street Dental",
      "name_source": "https://harborstreetdental.example/about",
      "website": "https://harborstreetdental.example",
      "website_source": "https://harborstreetdental.example/about",
      "source_url": "https://harborstreetdental.example/about",
      "checked_at": "2026-10-19T14:02:11Z"
    }
  ]
}
```

An import sends `collection.records_added` with `collection_id`, `imported` set to `true`, and the rows as you supplied them.

## Verify the signature

SpecStream signs every delivery with your webhook's secret. Check the signature before you trust the body.

1.  Read the raw request body. Don't parse it and write it out again, because the signature covers the exact bytes.
2.  Split `Specstream-Signature` into `t`, the time in Unix seconds, and `v1`, the signature.
3.  Compute the HMAC-SHA256 of `t`, a period, and the raw body, keyed with your secret. Write it as lowercase hex.
4.  Compare it with `v1` in constant time.
5.  Reject the delivery when `t` is more than five minutes from your clock.

Each attempt is signed again with its own time, so a retry that arrives late still passes.

### Python

Python

```python
import hashlib
import hmac
import os
import time

SECRET = os.environ["SPECSTREAM_WEBHOOK_SECRET"]


def verify(header: str, raw_body: bytes) -> bool:
    parts = dict(item.partition("=")[::2] for item in header.split(","))
    timestamp, received = parts.get("t", ""), parts.get("v1", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), received.encode())
```

Python

```python
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()


@app.post("/webhooks/specstream")
async def receive(request: Request):
    raw_body = await request.body()
    if not verify(request.headers.get("Specstream-Signature", ""), raw_body):
        raise HTTPException(status_code=401)
    event = await request.json()
    # Skip a repeat: the same Specstream-Event-Id means the same delivery.
    return {"ok": True}
```

### TypeScript

typescript

```typescript
import { createHmac, timingSafeEqual } from "node:crypto"

export function verify(header: string | null, rawBody: string, secret: string): boolean {
    const parts = Object.fromEntries((header ?? "").split(",").map((item) => item.split("=")))
    const timestamp = Number(parts.t)
    if (!parts.v1 || !Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false
    const expected = Buffer.from(createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"))
    const received = Buffer.from(parts.v1)
    return expected.length === received.length && timingSafeEqual(expected, received)
}
```

typescript

```typescript
import express from "express"

const app = express()

app.post("/webhooks/specstream", express.raw({ type: "application/json" }), (req, res) => {
    const rawBody = req.body.toString("utf8")
    if (!verify(req.header("Specstream-Signature") ?? null, rawBody, process.env.SPECSTREAM_WEBHOOK_SECRET!)) {
        return res.status(401).end()
    }
    const event = JSON.parse(rawBody)
    // Skip a repeat: the same Specstream-Event-Id means the same delivery.
    res.json({ ok: true })
})
```

## Attempts and retries

A 2xx answer within 10 seconds is a delivery. Anything else is a failed attempt, and SpecStream tries again: first after 30 seconds, then with the wait doubling up to one hour. After ten attempts the delivery is canceled and isn't sent again. A delivery in parts is tried again whole, each part with its same id.

Delivery is at least once, so the same event can reach you more than once. Use `Specstream-Event-Id` to skip a repeat. Each retry reads the records again, so a record that has been removed since the first attempt isn't sent.

**Agents & API** then **Webhooks** lists every delivery with its state: Delivered, Retry scheduled, or Canceled. The table shows the number of attempts and the next try, and a row opens to the last error.

## MCP Events

A client that supports MCP Events can subscribe a callback itself and hear about the same moments, with no webhook to create by hand. ChatGPT subscribes this way. The methods are `events/list`, `events/subscribe`, and `events/unsubscribe`, over the same [MCP endpoint](https://specstream.ai/docs/mcp.md).

The events are `find.completed`, `monitor.found`, and `collection.records_added`. Narrow one with a filter: `find_id` or `spec_id` on `find.completed`, `spec_id` or `schedule_id` on `monitor.found`, and `spec_id` on `collection.records_added`.

JSON

```json
{
  "method": "events/subscribe",
  "params": {
    "name": "find.completed",
    "arguments": { "spec_id": "spec_..." },
    "delivery": {
      "mode": "webhook",
      "url": "https://example.com/mcp-events",
      "secret": "whsec_..."
    }
  }
}
```

-   The callback has to be public HTTPS. The `secret` is one you make: `whsec_` followed by a base64 key of 24 to 64 bytes.
-   When you subscribe, SpecStream posts a message with `"type": "verification"` and a `challenge`. Answer 2xx with JSON that echoes the `challenge`.
-   A subscription lasts seven days unless you set `ttlMs`, which can be anywhere from one hour to 30 days. The reply's `refreshBefore` says when to subscribe again.
-   Answering a delivery with 410 ends the subscription.

MCP Events follow the [Standard Webhooks](https://www.standardwebhooks.com/) signing scheme, so any Standard Webhooks library verifies them. The headers are `webhook-id`, `webhook-timestamp`, and `webhook-signature`, plus `X-MCP-Subscription-Id`. The signature is `v1,` and the base64 HMAC-SHA256 of the id, the timestamp, and the raw body joined by periods, keyed with the decoded secret.

An MCP event delivery

```json
{
  "eventId": "dlv_...",
  "name": "find.completed",
  "timestamp": "2026-10-19T14:05:40Z",
  "data": {
    "find_id": "fnd_9Hw3vLpA",
    "spec_id": "spec_...",
    "schedule_id": null,
    "status": "completed",
    "results": 25,
    "new_results": 0,
    "changed_results": 0,
    "names": ["Harbor Street Dental"],
    "url": "https://specstream.ai/workspace/collections/fnd_9Hw3vLpA"
  },
  "cursor": null
}
```

`names` holds up to 20 names, the new and changed ones when there are any, and `url` opens the list in the workspace. Retries follow the same schedule as webhooks.

**Two signing schemes**

Webhooks you create in the workspace or with `/v1/account/webhooks` use `Specstream-Signature`. MCP Events use the Standard Webhooks headers. Verify each the way its section shows.

[Create an API key](https://specstream.ai/docs/account.md#keys) · [REST API](https://specstream.ai/docs/api.md) · [Keeping data current](https://specstream.ai/docs/schedules.md)
