Skip to content

Integration guide

Customer webhooks

Receive a durable, signed verdict.changed event when RealExploit changes its verdict for a CVE. Delivery is at least once and may arrive out of order: verify the raw bytes, reject replays, and deduplicate every delivery id before acting.

Customer webhooks appear in the console only after they are enabled for the current environment. A hidden console entry means rollout is not live there yet; no endpoint-management request is made.

Plans and ownership

PlanEndpointsOwner
Free0May remove a revoked legacy tombstone after downgrade
Pro3Individual user
TeamUnlimited by planOrganization
EnterpriseUnlimited by planOrganization

“Unlimited by plan” means there is no lower commercial endpoint-count limit. Fair-use security controls allow up to 1,000 active or paused endpoints per owner, up to three destinations per hostname, 10 create or secret-rotation requests per user per minute, and 100 endpoint-creation attempts per owner in a rolling 24-hour window. Rejected creation attempts count toward the daily owner limit; secret rotations do not. Organization owners and admins manage Team and Enterprise endpoints.

Configure and test

  1. Open Webhooks in the authenticated console and add a public HTTPS destination on port 443. The hostname must have a public IPv4 address; literal IPs, redirects, fragments, embedded credentials, and any non-public DNS answer are rejected.
  2. Copy the signing secret from the one-time modal into your secret manager. It is never returned by list/detail requests and cannot be recovered later.
  3. Select Test. POST /v1/webhooks/{id}/test returns 202 with a pending delivery id; it does not perform inline egress.
  4. Poll the endpoint’s delivery history until that id is succeeded or terminal. A 2xx from your receiver means success.
{
  "delivery_id": 981,
  "event_type": "webhook.test",
  "status": "pending"
}

Headers and signed bytes

Every attempt carries these headers:

X-RealExploit-Timestamp: <Unix seconds>
X-RealExploit-Signature: v1=<lowercase HMAC-SHA256 hex>
X-RealExploit-Delivery-ID: <stable delivery id>
X-RealExploit-Event: verdict.changed | webhook.test
X-RealExploit-Event-Id: <stable event id>
X-RealExploit-Schema-Version: 1

The exact HMAC message is:

<timestamp>.<delivery_id>.<raw HTTP body bytes>

Verify against the untouched request body before parsing JSON. Do not parse and reserialize it: even equivalent JSON can produce different bytes. Reject timestamps outside your replay window and compare the signature in constant time.

Python verification

import hashlib
import hmac
import time


def verify_webhook(secret: str, headers: dict[str, str], raw_body: bytes) -> bool:
    timestamp_text = headers.get("x-realexploit-timestamp", "")
    delivery_id = headers.get("x-realexploit-delivery-id", "")
    received = headers.get("x-realexploit-signature", "")
    if not isinstance(raw_body, bytes):
        return False
    if not timestamp_text.isascii() or not timestamp_text.isdigit():
        return False
    if not delivery_id.isascii() or not delivery_id.isdigit() or int(delivery_id) <= 0:
        return False
    timestamp = int(timestamp_text)
    if abs(int(time.time()) - timestamp) > 300:
        return False
    message = f"{timestamp_text}.{delivery_id}.".encode("utf-8") + raw_body
    digest = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
    signature_ok = hmac.compare_digest(f"v1={digest}", received)
    return signature_ok and headers.get("x-realexploit-schema-version") == "1"

Node.js verification

Your HTTP framework must give this function the original Buffer, not a parsed object.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(secret, headers, rawBody) {
  const timestamp = String(headers["x-realexploit-timestamp"] ?? "");
  const deliveryId = String(headers["x-realexploit-delivery-id"] ?? "");
  const receivedText = String(headers["x-realexploit-signature"] ?? "");
  if (!Buffer.isBuffer(rawBody)) return false;
  if (!/^\d+$/.test(timestamp)) return false;
  if (!/^[1-9]\d*$/.test(deliveryId)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
  const message = Buffer.concat([
    Buffer.from(`${timestamp}.${deliveryId}.`, "utf8"),
    rawBody,
  ]);
  const expected = Buffer.from(
    `v1=${createHmac("sha256", secret).update(message).digest("hex")}`,
    "utf8",
  );
  const received = Buffer.from(receivedText, "utf8");
  const signatureOk = expected.length === received.length && timingSafeEqual(expected, received);
  return signatureOk && String(headers["x-realexploit-schema-version"] ?? "") === "1";
}

Replay safety and idempotency

Event body

{
  "data": {
    "cve_id": "CVE-2021-44228",
    "previous_verdict": "POC_AVAILABLE",
    "score": 95,
    "score_version": 1,
    "verdict": "ACTIVELY_EXPLOITED"
  },
  "event_id": 123,
  "occurred_at": "2026-08-10T02:00:00Z",
  "schema_version": 1,
  "type": "verdict.changed"
}

Bodies are compact, sorted-key UTF-8 JSON. Delivery history exposes the public CVE transition and bounded delivery status only; RealExploit does not persist response bodies or arbitrary remote error text.

Version 1 has no per-CVE subscription filter. Each eligible active endpoint receives every globally captured verdict.changed event plus its own manual tests. Dimension your receiver accordingly and filter only after verifying the signature.

Retries and terminal results

RealExploit makes up to nine attempts: initial delivery, then roughly 1 minute, 5 minutes, 25 minutes, 2 hours, 6 hours, 12 hours, 18 hours, and 24 hours later. Retry-After is honored for 429/503 within a bounded 1-minute to 6-hour window plus jitter, but never beyond the absolute 24-hour retry-cycle deadline. An authorized manual retry starts a new cycle without deleting the prior attempt audit.

Ready to integrate?

Check current plan pricing, then use the authenticated console when Webhooks is visible for your environment.