Docs
Integrations

Webhooks

Webhooks deliver real-time, signed event notifications to your application — exposure alerts, domain verification, usage thresholds, and subscription changes.

Event types

Subscribe to any subset when you create a webhook. More events are added over time; unknown events should be ignored gracefully.

EventGroupFires when
exposureAlert.createdExposureA monitored identity appeared in breach/stealer-log data.
exposureAlert.severity_changedExposureAn existing alert's severity was re-scored.
exposureAlert.resolvedExposureAn alert was resolved (in-app or via API).
monitoredDomain.verifiedDomainDomain ownership verification succeeded.
monitoredDomain.verification_failedDomainDomain verification failed or expired.
usage.threshold_reachedUsageMonthly usage crossed 50 / 75 / 90 / 100% of quota.
subscription.tier_changedBillingPlan tier changed (upgrade/downgrade/bundle/cycle).
subscription.suspendedBillingSubscription suspended after failed-payment dunning.
subscription.reactivatedBillingA suspended subscription recovered.

Payload envelope

Every delivery is a JSON POST with a stable envelope. The event-specific fields live under data.

webhook-payload.jsonjson
{
  "id": "<deliveryId>",            // unique per delivery — dedupe on this
  "event": "exposureAlert.created",
  "ts": 1780000000000,             // ms epoch the event fired
  "organizationId": "<orgId>",
  "apiVersion": 1,
  "data": { /* event-specific, see below */ }
}

Request headers: X-LeakJar-Signature (see below), x-leakjar-event (the event key), and x-leakjar-delivery (the delivery id).

Event data fields

exposureAlert.* — exposure alert events. Weaponizable context (session cookie / browser fingerprint) is included only for Scale-tier subscriptions and never on lower tiers.

exposureAlert.created → datajson
{
  "id": "<alertId>",
  "projectId": "<projectId>",
  "monitoredDomainId": "<domainId>",
  "email": "user@example.com",
  "source": "breach:example-2026",
  "breachDate": 1775000000000,    // ms epoch | null
  "severity": "high",             // low | medium | high | critical
  "status": "new"                 // new | acknowledged | resolved | ignored | false_positive
}

monitoredDomain.verified / verification_failed:

monitoredDomain.* → datajson
{ "id": "<domainId>", "domain": "example.com", "verifiedAt": 1780000000000 }
// verification_failed adds: "reason": "TXT record not found"

usage.threshold_reached and subscription.*:

usage / subscription → datajson
// usage.threshold_reached
{ "kind": "bpd.check", "percentOfCap": 90, "monthlyCap": 1000000,
  "usedThisMonth": 901234, "tier": "pp_growth", "monthResetAt": 1782000000000 }

// subscription.tier_changed
{ "subscriptionId": "<id>", "product": "password_protect",
  "fromTier": "pp_starter", "toTier": "pp_growth", "reason": "upgrade" }

Setup

  1. In the Console, open a project → Webhooks.
  2. Add an HTTPS endpoint and select the events to receive.
  3. Copy the signing secret shown once at creation — used to verify signatures.
  4. Send a test ping and confirm delivery in the per-webhook delivery log.

Signature verification

Each delivery carries an X-LeakJar-Signature header of the form t=<unix>,v1=<hex>. The signature is HMAC-SHA256(secret, "<t>.<rawBody>"). Reject deliveries whose timestamp is more than 5 minutes old (replay protection) or whose signature doesn’t match.

verify-webhook.tstypescript
import { createHmac, timingSafeEqual } from "crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=")),
  );
  const t = parts.t, v1 = parts.v1;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Handling events

Verify, then acknowledge with a 200 quickly and process asynchronously. Failed deliveries retry with exponential backoff (up to 5 attempts); dedupe on the envelope id.

webhook-handler.tstypescript
export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get("x-leakjar-signature") ?? "";
  if (!verify(body, sig, process.env.LEAKJAR_WEBHOOK_SECRET!)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const evt = JSON.parse(body);
  switch (evt.event) {
    case "exposureAlert.created":
      await queue.enqueue("triage-exposure", evt.data);
      break;
    case "monitoredDomain.verified":
      await queue.enqueue("domain-verified", evt.data);
      break;
    case "usage.threshold_reached":
      await queue.enqueue("usage-alert", evt.data);
      break;
    // ...handle other events; ignore unknown ones
  }
  return new Response("OK", { status: 200 });
}