Webhooks
Owners of claimed servers can add up to three webhook endpoints in My servers. Each endpoint receives every alert event of every server the account monitors, one event per request, right after protogrid detects it (within about 15 minutes). Unlike the email digest there is no daily grouping.
Endpoints
Section titled “Endpoints”https://only, on port 443, on a public hostname: no IP addresses, no credentials in the URL.- protogrid never follows redirects; a 3xx counts as a failure.
- Answer with any 2xx within 10 seconds; the response body is ignored.
- Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then dropped.
- 20 failed attempts in a row disable the endpoint; My servers shows why and lets you enable it again.
- Send test event delivers a
ping(five a day).
Payload
Section titled “Payload”{ "id": "evt_1234", "type": "tools_changed", "version": "2026-09-24", "occurred_at": "2026-09-24T18:00:00.000Z", "server": { "name": "io.github.acme/acme-mcp", "url": "https://protogrid.dev/servers/io.github.acme/acme-mcp" }, "detail": { "count": 2, "tools": ["search", "fetch"], "rug_pulls": ["search"] }, "links": { "server": "https://protogrid.dev/servers/io.github.acme/acme-mcp", "quality": "https://api.protogrid.dev/v1/servers/io.github.acme%2Facme-mcp/quality", "account": "https://protogrid.dev/account/servers" }}type |
detail |
|---|---|
tools_changed |
count, tools, rug_pulls (tools that kept their name but changed most of their description), kinds |
check_failed |
checks: { id, detail } of the quality checks that started failing |
unreachable |
every remote endpoint has failed for more than 2 hours |
score_drop |
from, to |
recovered |
what: reachable, or checks with the ids fixed |
ping |
a test; server is null |
Headers: Protogrid-Event-Id (the same as id; use it to drop duplicates, since a retry can follow a delivery your endpoint received but did not acknowledge), Protogrid-Delivery-Id, Protogrid-Webhook-Version, and Protogrid-Signature.
Verify the signature
Section titled “Verify the signature”Protogrid-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of <t>.<raw body> with the endpoint’s signing secret (whsec_…, shown once when you add the endpoint or rotate the secret).
Compute it over the raw request body, compare in constant time, and refuse timestamps older than five minutes so a captured request cannot be replayed.
Node:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const want = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")); const got = Buffer.from(parts.v1); return want.length === got.length && timingSafeEqual(want, got);}Python:
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, header: str, tolerance_seconds: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - t) > tolerance_seconds or "v1" not in parts: return False want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(want, parts["v1"])protogrid stores the secret encrypted and cannot show it again; if you lose it, rotate it.