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.
| Event | Group | Fires when |
|---|---|---|
| exposureAlert.created | Exposure | A monitored identity appeared in breach/stealer-log data. |
| exposureAlert.severity_changed | Exposure | An existing alert's severity was re-scored. |
| exposureAlert.resolved | Exposure | An alert was resolved (in-app or via API). |
| monitoredDomain.verified | Domain | Domain ownership verification succeeded. |
| monitoredDomain.verification_failed | Domain | Domain verification failed or expired. |
| usage.threshold_reached | Usage | Monthly usage crossed 50 / 75 / 90 / 100% of quota. |
| subscription.tier_changed | Billing | Plan tier changed (upgrade/downgrade/bundle/cycle). |
| subscription.suspended | Billing | Subscription suspended after failed-payment dunning. |
| subscription.reactivated | Billing | A suspended subscription recovered. |
Payload envelope
Every delivery is a JSON POST with a stable envelope. The event-specific fields live under data.
{
"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.
{
"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:
{ "id": "<domainId>", "domain": "example.com", "verifiedAt": 1780000000000 }
// verification_failed adds: "reason": "TXT record not found"usage.threshold_reached and subscription.*:
// 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
- In the Console, open a project → Webhooks.
- Add an HTTPS endpoint and select the events to receive.
- Copy the signing secret shown once at creation — used to verify signatures.
- 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.
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.
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 });
}