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 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
Webhook access is available on plans that include operational delivery. If webhook management is unavailable for your API key, use filtered polling endpoints instead.
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
- You register a webhook with an HTTPS URL and the event types you want (
radar, signal, or both).
- Shoal’s event poller checks for new events every ~60 seconds.
- When a matching event is found, Shoal queues a delivery and POSTs the event payload to your URL.
- Your endpoint must respond with a
2xx status within 10 seconds. Any other response triggers retries.
Every webhook delivery is a POST request with these headers and body:
| 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
{
"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.
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)
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.
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 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:
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);
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