Live

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 viaFires onManage
Settings → WebhooksAlerts on wallets you follow, directly or through a followed listThe settings page
POST /api/v1/webhooksSignals matching filters you set on the endpoint itselfThe 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.

POST /api/v1/webhooks
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:

json
{
  "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:

json
{
  "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

HeaderDescription
X-CrowdIntel-SignatureHMAC-SHA256 of ${timestamp}.${rawBody}, hex-encoded, signed with your endpoint secret.
X-CrowdIntel-TimestampUnix epoch milliseconds at send time. Reject requests whose timestamp is too old to block replays.
User-AgentCrowdIntel-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

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

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 "", 200

Responses 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 httphttps 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.
Live
Beta
···v0.1.0
CrowdIntel needs a newer browser

Your device is on iOS 15 or older. CrowdIntel uses features (color-mix, WebGL2, modern auth) that require iOS 16+ / Safari 16+.

iPhone 7 maxes out at iOS 15, so the site can't render here. Open CrowdIntel on a desktop browser or a newer phone.

crowdintel.xyz

◆ legacy browser fallback