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

# Webhooks

> Receive real-time push notifications for radar and signal events

Webhooks let you receive events as they happen instead of polling. When a new radar or signal event is detected, Shoal sends an HTTP POST to your registered URL with the event payload.

Webhook event bodies follow the same canonical event contract as the REST API. New consumers should build against the canonical names:

* `summary`
* `bullets`
* `owners`
* `participants`
* `evidence`
* `latestEvidenceTimestamp`
* `significance`

<Note>
  Webhook access is available on plans that include operational delivery. If webhook management is unavailable for your API key, use filtered polling endpoints instead.
</Note>

Scoped webhook management is designed for operational integrations:

* subscribe to `radar`, `signal`, or both
* manage up to 5 webhook endpoints per account
* receive the same canonical event payloads used elsewhere in the API

Bulk/global delivery or higher-throughput delivery should be treated as a separate premium surface, not assumed to be part of basic webhook management.

## How It Works

1. You [register a webhook](/api-reference/webhooks/create) with an HTTPS URL and the event types you want (`radar`, `signal`, or both).
2. Shoal's event poller checks for new events every \~60 seconds.
3. When a matching event is found, Shoal queues a delivery and POSTs the event payload to your URL.
4. Your endpoint must respond with a `2xx` status within 10 seconds. Any other response triggers retries.

## Payload Format

Every webhook delivery is a POST request with these headers and body:

### Headers

| Header              | Description                            |
| ------------------- | -------------------------------------- |
| `Content-Type`      | `application/json`                     |
| `X-Shoal-Signature` | HMAC-SHA256 signature for verification |
| `X-Shoal-Event`     | Event type: `radar` or `signal`        |
| `X-Shoal-Delivery`  | Unique delivery ID                     |

### Body

```json theme={null}
{
  "event_type": "radar",
  "event": {
    "id": 1234,
    "title": "Ethereum Foundation announces grants program",
    "eventCategory": "funding",
    "eventSubcategory": "grants",
    "summary": "...",
    "bullets": ["..."],
    "owners": [
      { "id": 526, "label": "Ethereum", "type": "project", "aliases": ["ETH"] }
    ],
    "participants": [...],
    "evidence": [
      {
        "id": 9876,
        "content": "...",
        "url": "https://...",
        "timestamp": "2026-03-10T14:00:00Z"
      }
    ],
    "latestEvidenceTimestamp": "2026-03-10T14:00:00Z",
    "globalSummary": "...",
    "bulletSummary": ["..."],
    "eventOwner": [
      { "id": 526, "label": "Ethereum", "type": "project", "aliases": ["ETH"] }
    ],
    "eventParticipants": [...],
    "posts": [
      {
        "id": 9876,
        "content": "...",
        "url": "https://...",
        "timestamp": "2026-03-10T14:00:00Z"
      }
    ],
    "latestPostTimestamp": "2026-03-10T14:00:00Z",
    "significance": 8.4
  },
  "timestamp": "2026-03-10T14:00:30Z"
}
```

Legacy compatibility fields are still present in webhook deliveries:

* `globalSummary` -> `summary`
* `bulletSummary` -> `bullets`
* `eventOwner` -> `owners`
* `eventParticipants` -> `participants`
* `posts` -> `evidence`
* `latestPostTimestamp` -> `latestEvidenceTimestamp`
* `signal` -> `significance`

## Verifying Signatures

Every delivery includes an `X-Shoal-Signature` header containing an HMAC-SHA256 signature of the request body, using the `secret` returned when you created the webhook. Always verify this signature to confirm the request came from Shoal.

```python Python theme={null}
import hmac, hashlib

def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# In your webhook handler:
# signature = request.headers["X-Shoal-Signature"]
# is_valid = verify_signature(request.body, signature, WEBHOOK_SECRET)
```

```javascript JavaScript theme={null}
import crypto from 'node:crypto';

function verifySignature(body, signature, secret) {
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(body).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// In your webhook handler:
// const signature = req.headers['x-shoal-signature'];
// const isValid = verifySignature(req.rawBody, signature, WEBHOOK_SECRET);
```

## Retry Policy

Failed deliveries are retried with exponential backoff:

| Attempt | Delay      |
| ------- | ---------- |
| 1       | Immediate  |
| 2       | 15 seconds |
| 3       | 1 minute   |
| 4       | 5 minutes  |
| 5       | 30 minutes |

After 5 failed attempts, the delivery is marked as `failed`. You can inspect failed deliveries via [`GET /v1/webhooks/:id`](/api-reference/webhooks/get).

## Auto-Disable

If a webhook accumulates **50 consecutive failed deliveries** with no successes in between, it is automatically disabled (`active` set to `false`). Re-enable it with [`PATCH /v1/webhooks/:id`](/api-reference/webhooks/update) after fixing your endpoint.

## Limits

| Limit                  | Value                   |
| ---------------------- | ----------------------- |
| Webhooks per account   | 5                       |
| Payload size           | 256 KB max              |
| Response timeout       | 10 seconds              |
| Retry attempts         | 5 per delivery          |
| Auto-disable threshold | 50 consecutive failures |

## Example: Receiving Webhooks

A minimal Express server that receives and verifies Shoal webhook events:

```javascript JavaScript theme={null}
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const WEBHOOK_SECRET = process.env.SHOAL_WEBHOOK_SECRET;

app.post('/shoal-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-shoal-signature'];
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', WEBHOOK_SECRET).update(req.body).digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(401).send('Invalid signature');
  }

  const payload = JSON.parse(req.body);
  console.log(`[${payload.event_type}] ${payload.event.title}`);

  // Process the event...

  res.status(200).send('OK');
});

app.listen(3000);
```

```python Python theme={null}
from flask import Flask, request, abort
import hmac, hashlib, json, os

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["SHOAL_WEBHOOK_SECRET"]

@app.route("/shoal-webhook", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Shoal-Signature", "")
    body = request.get_data()

    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(), body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        abort(401)

    payload = json.loads(body)
    print(f"[{payload['event_type']}] {payload['event']['title']}")

    # Process the event...

    return "OK", 200
```
