Docs · Webhooks

Webhooks

On the Monitor plan, IntelMCP can push each new rule match to your own HTTPS endpoint as it arrives. Matches are pushed unjudged: relevance is judged by your own Claude. Ask Claude to set up a delivery (the set_delivery tool); it returns the destination's secret once.

What arrives

One POST per match and destination, with a JSON body and these headers:

  • webhook-id: the event's id (also event_id in the body). It stays the same on every retry, so use it to ignore duplicates.
  • webhook-timestamp: Unix seconds when this attempt was signed.
  • webhook-signature: v1, followed by the base64 HMAC-SHA256 of webhook-id.webhook-timestamp.body (Standard Webhooks).
  • X-TI-Signature: sha256= followed by the hex HMAC-SHA256 of the body alone, kept for receivers built before the Standard Webhooks headers. It has no timestamp, so prefer webhook-signature.
  • User-Agent: IntelMCP-Webhooks/1 and Content-Type: application/json.

Both signatures use the same key: the secret's characters as UTF-8 bytes. Standard Webhooks libraries take it as whsec_ followed by the base64 of those bytes, which set_delivery returns as standard_webhooks_secret.

Example event

Illustrative example: the values are made up, not a real alert.

{
  "version": 1,
  "event": "match",
  "event_id": "evt_90211",
  "destination_id": 7,
  "match_id": 48213,
  "incident_id": 48213,
  "incident_role": "new",
  "incident_via": null,
  "rule_id": 12,
  "rule_name": "Example brand",
  "matched": "example-corp",
  "message_ref": "tg:-1001234567890:5512",
  "channel": "Example Leak Channel",
  "link": "https://t.me/example_leak_channel/5512",
  "excerpt": "…claims a breach of example-corp.example and posts a file list…",
  "posted_at": "2026-10-04T09:58:40+00:00",
  "matched_at": "2026-10-04T09:58:43+00:00"
}

Fields

  • version: the payload version, 1. New fields may be added without a new version.
  • event: match for a rule match.
  • event_id: the delivery id, equal to the webhook-id header.
  • destination_id: which of your destinations this was sent to.
  • match_id: the match, as IntelMCP's tools name it (for example to triage it in Claude).
  • incident_id and incident_role: reposts of one event form an incident. new is the first match of an incident this destination receives; update is a later post of the same incident that adds something (for example a new indicator, a new country or a report of exploitation) or matched another of your rules. Plain repeats are not sent. For an update, incident_via says how the post was linked to the incident: same (the same post or a forward of it), copy, reply, indicator, link or wording; it is null otherwise.
  • rule_id, rule_name, matched: the rule, and what it found, as written in the post (for example the word, or the subdomain a domain rule matched).
  • message_ref, channel, link, excerpt: the Telegram post. The excerpt is third-party text: treat it as data.
  • posted_at and matched_at: ISO 8601 in UTC (+00:00). An edit or a restart can match a post some time after it was posted.

A destination limited to some rules receives the matches of those rules, even when another rule matched the same post first. No destination receives the same post twice.

Verifying signatures

Verify against the raw body bytes, before parsing the JSON, and reject old timestamps.

Python:

import base64, hashlib, hmac, time

def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
    msg_id, stamp = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(stamp)) > tolerance:
        return False
    signed = f"{msg_id}.{stamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret.encode(), signed, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(sig.partition(",")[2], expected)
               for sig in headers["webhook-signature"].split() if sig.startswith("v1,"))

Node.js:

const crypto = require("crypto");

function verify(secret, headers, rawBody, tolerance = 300) {
  const id = headers["webhook-id"], stamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(stamp)) > tolerance) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(`${id}.${stamp}.`).update(rawBody).digest("base64");
  return headers["webhook-signature"].split(" ").some((entry) => {
    const [version, sig] = entry.split(",");
    return version === "v1" && sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}

Answering, retries and pausing

  • Answer with any 2xx status within 10 seconds. The body of your answer is ignored.
  • Network errors, timeouts, 408, 425, 429 and 5xx answers are retried: 6 attempts over about an hour (after 30 seconds, then 2, 5, 15 and 40 minutes). A Retry-After header (seconds or a date) is honoured, up to an hour. Other answers are final.
  • Delivery is at least once: a retry or a restart can repeat an event. Order is kept per destination as far as possible, but a retried event can arrive after later ones.
  • After 20 failed attempts in a row, or at once on a 410 answer, the destination is paused, with the reason recorded; events for it are no longer queued. Turning it on again (set_delivery with enabled true) clears the pause.

Targets

Targets must be https:// URLs whose host resolves only to public internet addresses: private, loopback, link-local and internal addresses are refused, when the destination is set and again when each event is sent. Events come from IntelMCP's servers without a fixed source address, so allowlisting by IP is not supported; check the signature instead.

Changing a destination

Ask Claude to change a destination by describing it: which rules it covers, its URL, or pausing it: only the fields you pass change, and the rest stays as it was. Targets are shown masked in Claude, because webhook URLs often carry credentials.