Skip to main content
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 full event payload.

How It Works

  1. You register a webhook 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

HeaderDescription
Content-Typeapplication/json
X-Shoal-SignatureHMAC-SHA256 signature for verification
X-Shoal-EventEvent type: radar or signal
X-Shoal-DeliveryUnique delivery ID

Body

{
  "event_type": "radar",
  "event": {
    "id": 1234,
    "title": "Ethereum Foundation announces grants program",
    "eventCategory": "funding",
    "eventSubcategory": "grants",
    "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"
  },
  "timestamp": "2026-03-10T14:00:30Z"
}

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
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
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:
AttemptDelay
1Immediate
215 seconds
31 minute
45 minutes
530 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

LimitValue
Webhooks per account5
Payload size256 KB max
Response timeout10 seconds
Retry attempts5 per delivery
Auto-disable threshold50 consecutive failures

Example: Receiving Webhooks

A minimal Express server that receives and verifies Shoal webhook events:
JavaScript
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
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