Webhooks — real-time alert delivery
HMAC-signed webhook delivery for signals and followed-wallet alerts — payload schema, signature verification, retries, and the endpoints that manage them.
Webhooks push a signed JSON payload to your server the moment a signal fires, so you stop spending rate limit polling markets where nothing happened. Every plan gets webhooks: one endpoint on the free tier, ten on Terminal and above.
Two ways to create one
| Created via | Fires on | Manage |
|---|---|---|
| Settings → Webhooks | Alerts on wallets you follow, directly or through a followed list | The settings page |
POST /api/v1/webhooks | Signals matching filters you set on the endpoint itself | The API, or the settings page |
Neither is a global firehose. The follow-based channel delivers only for wallets in your follow set; the API-created channel delivers only what its own filters match.
Both use the same signing scheme, the same headers and the same retry behaviour. They differ in one visible way: the event field, and the shape of the object beside it.
Managing endpoints over the API
POST /api/v1/webhooks creates an endpoint. The callback URL must be absolute and https, and must resolve to a publicly routable host — localhost, .local, .internal and private IP literals are rejected at creation rather than failing silently at delivery time.
curl -X POST "https://crowdintel.xyz/api/v1/webhooks" \
-H "Authorization: Bearer $CROWDINTEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"callback_url": "https://example.com/hooks/crowdintel",
"min_score": 80,
"score_type": "insider",
"category": "politics"
}'Filters are optional: min_score (0–100), score_type (whale or insider), and category. Omitting one means no constraint on it.
GET /api/v1/webhooks lists your endpoints with secret returned as null — it is never re-exposed after creation. DELETE /api/v1/webhooks/{id} removes one.
Exceeding your plan's endpoint count returns 403 naming the cap.
Payload
The follow-based channel sends:
Every delivery is a POST with a JSON body shaped like this:
{
"event": "alert.followed_wallet",
"alert": {
"type": "insider",
"id": 12345,
"score": 82,
"betValue": 25000,
"side": "BUY",
"outcome": "Yes",
"price": 0.42,
"explanation": "High win rate at uncertain odds…",
"createdAt": "2026-05-27T12:00:00.000Z"
},
"wallet": {
"address": "0x1234…5678",
"type": "insider",
"profileUrl": "https://crowdintel.xyz/whales/0x1234…5678"
},
"market": {
"title": "Will X happen by 2026?",
"category": "politics",
"slug": "will-x-happen-by-2026"
},
"followedVia": "wallet"
}followedVia is "wallet" for a direct follow or "list:<slug>" when the wallet came from a list you follow.
An endpoint created over the API sends the same envelope with event set to signal.whale or signal.insider, the alert object under signal instead of alert, and no followedVia:
{
"event": "signal.insider",
"signal": {
"type": "insider",
"id": 12345,
"score": 82,
"betValue": 25000,
"side": "BUY",
"outcome": "Yes",
"price": 0.42,
"explanation": "…",
"createdAt": "2026-08-16T12:00:00.000Z"
},
"wallet": { "address": "0x1234…5678", "type": "insider", "profileUrl": "https://crowdintel.xyz/whales/0x1234…5678" },
"market": { "title": "Will X happen by 2026?", "category": "politics", "slug": "will-x-happen-by-2026" }
}Delivery payload keys are camelCase. This is the one place the product does not use the snake_case convention the REST API follows — branch on event, and read the object it names.
Headers
| Header | Description |
|---|---|
X-CrowdIntel-Signature | HMAC-SHA256 of ${timestamp}.${rawBody}, hex-encoded, signed with your endpoint secret. |
X-CrowdIntel-Timestamp | Unix epoch milliseconds at send time. Reject requests whose timestamp is too old to block replays. |
User-Agent | CrowdIntel-Webhooks/1 |
Verifying the signature
Compute the HMAC over the exact raw request body prefixed with the timestamp and a dot (${timestamp}.${body}), then compare it to X-CrowdIntel-Signature in constant time. Your secret is shown once when you create the endpoint (prefix whsec_).
Node.js
import crypto from "node:crypto";
function verify(rawBody, signature, timestamp, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
// Reject deliveries older than 5 minutes (replay protection).
const fresh = Math.abs(Date.now() - Number(timestamp)) < 5 * 60 * 1000;
return ok && fresh;
}
// Express: capture the RAW body so the bytes match what we signed.
app.post("/webhooks/crowdintel", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const valid = verify(
rawBody,
req.header("X-CrowdIntel-Signature"),
req.header("X-CrowdIntel-Timestamp"),
process.env.CROWDINTEL_WEBHOOK_SECRET,
);
if (!valid) return res.status(401).end();
const payload = JSON.parse(rawBody);
// … handle payload …
res.status(200).end();
});Python
import hashlib
import hmac
import time
def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return False
# Reject deliveries older than 5 minutes (replay protection).
return abs(time.time() * 1000 - int(timestamp)) < 5 * 60 * 1000
# Flask
@app.post("/webhooks/crowdintel")
def crowdintel_webhook():
raw = request.get_data() # raw bytes, before JSON parsing
ok = verify(
raw,
request.headers.get("X-CrowdIntel-Signature", ""),
request.headers.get("X-CrowdIntel-Timestamp", ""),
os.environ["CROWDINTEL_WEBHOOK_SECRET"],
)
if not ok:
abort(401)
payload = request.get_json()
# … handle payload …
return "", 200Responses and retries
Return a 2xx status to acknowledge a delivery. Any non-2xx response (or a timeout — we wait 10 seconds) is retried with exponential backoff: 5s, then 30s, then 5min. After the fourth failed attempt the delivery is marked dead and surfaced in your recent deliveries list. Successful and dead deliveries are not retried.
Redirects are not followed. A 3xx is a failure, so point the endpoint at its final URL — an http → https redirect will burn all four attempts.
Endpoints should be idempotent: a given alert is delivered at most once per endpoint, but design your handler to tolerate an occasional duplicate.
Testing
Use the Test button on Settings → Webhooks to send a sample payload to your endpoint and confirm your signature verification works before a real alert fires.
Next
- Alerts and delivery — presets, per-wallet filters, and why following subscribes you to nothing.
- API overview — auth, rate limits and the error envelope.
