> ## Documentation Index
> Fetch the complete documentation index at: https://docs.leakcheck.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive breach-monitoring alerts as signed JSON POST requests on your own endpoint — payload, headers, signature verification and delivery rules.

A **webhook** contact is an HTTPS endpoint of yours that receives every
breach-monitoring alert as a JSON `POST`. Add it in the Alerts dialog of the
[Breach monitoring page](https://leakcheck.io/notify) (the **Webhook** tile) —
see [Alerts & contacts](/monitoring/alerts) for the dialog itself.

## Endpoint requirements

* **HTTPS** on a publicly reachable host (no private or link-local addresses, no
  credentials in the URL, no redirects — a redirect counts as a failure).
* Answer with any **2xx** status within **5 seconds**. The response body is
  ignored; do your processing after replying.
* Expect the request from a shared, changing source address. We do not publish a
  fixed IP to allow-list — authenticate deliveries with the **signature** below.

## Request

```http theme={null}
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: LeakCheck-Alerts/1.0
X-LeakCheck-Timestamp: 1758542400
X-LeakCheck-Signature: sha256=5d0f2c1b9e…
```

### Payload

```json theme={null}
{
  "event": "monitor.new_leaks",
  "id": "3f4c1c0e-6b0d-4c5a-9a7e-2d3b1f8e9a10",
  "sent_at": "2026-09-22T12:00:00+00:00",
  "monitor": { "value": "john.doe@example.com", "type": "email" },
  "sources": [
    { "name": "Example.com", "fields": ["email", "password"] },
    { "name": "Stealer Logs", "fields": ["email", "origin", "password", "username"] }
  ],
  "url": "https://leakcheck.io/dashboard"
}
```

**event** `string`

`monitor.new_leaks` for a real alert, `test` for the sample sent by **Send test**.

**id** `string`

A UUID unique to this delivery. Use it to ignore a delivery you have already processed.

**sent\_at** `string`

When the alert was sent, ISO 8601.

**monitor** `object`

The monitored `value` and its `type`: `email`, `username`, `phone` or `domain`.

**sources** `array`

The breach sources the new records come from. `fields` lists the kinds of data
each source exposes — the names of the columns, never their contents.

**url** `string`

Where to see the records: the dashboard search.

**summary** `object`

Present for **domain monitors** (Enterprise) only:

```json theme={null}
"summary": {
  "counts": { "total": 37, "domain": 21, "origin": 16, "accounts": 29 },
  "accounts": [
    { "account": "j.smith@example.com", "kinds": ["domain", "origin"], "count": 3 },
    { "account": "outsider@gmail.com", "kinds": ["origin"], "count": 1 }
  ]
}
```

`counts.total` new records, of which `domain` belong to the domain's own e-mail
accounts and `origin` are info-stealer log-ins to the company's sites; `accounts`
is the number of distinct accounts affected. `accounts[]` lists the most affected
ones (up to 100), each with the `kinds` it appears in and its record `count`.

> [!NOTE]
>
> Alerts never contain passwords or any other leaked values — only the sources
> and the kinds of data. The records themselves are in the dashboard.

## Verifying the signature

Every delivery is signed with the contact's **secret** — shown once, right after
the contact is added. The signature is an HMAC-SHA256 over the timestamp and the
raw request body, joined with a dot:

```text theme={null}
X-LeakCheck-Signature = "sha256=" + hex( HMAC_SHA256( secret, X-LeakCheck-Timestamp + "." + body ) )
```

Compute it over the **raw body bytes** as received (not a re-serialised JSON),
compare with a constant-time comparison, and reject deliveries whose timestamp
is older than a few minutes to rule out replays.

```python Python (Flask) theme={null}
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["LEAKCHECK_WEBHOOK_SECRET"].encode()

@app.post("/leakcheck")
def leakcheck():
    ts = request.headers.get("X-LeakCheck-Timestamp", "")
    sig = request.headers.get("X-LeakCheck-Signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(400)
    expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)
    alert = request.get_json()
    print(alert["monitor"]["value"], [s["name"] for s in alert["sources"]])
    return "", 204
```

```javascript Node.js (Express) theme={null}
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const secret = process.env.LEAKCHECK_WEBHOOK_SECRET;

// Keep the raw body: the signature covers the bytes as sent.
app.post("/leakcheck", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-LeakCheck-Timestamp") ?? "";
  const sig = req.get("X-LeakCheck-Signature") ?? "";
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

  const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.`).update(req.body).digest("hex");
  if (expected.length !== sig.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return res.sendStatus(401);

  const alert = JSON.parse(req.body);
  console.log(alert.monitor.value, alert.sources.map((s) => s.name));
  res.sendStatus(204);
});
```

```php PHP theme={null}
<?php
$secret = getenv('LEAKCHECK_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_LEAKCHECK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_LEAKCHECK_SIGNATURE'] ?? '';

if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
    http_response_code(400); exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401); exit;
}

$alert = json_decode($body, true);
error_log($alert['monitor']['value'] . ': ' . implode(', ', array_column($alert['sources'], 'name')));
http_response_code(204);
```

## Delivery rules

* One request per alert per contact. A connection error or a `5xx` answer is
  retried once, shortly after; any other non-2xx answer is a failure.
* Monitors are checked once a day and after every import, so a busy monitor can
  produce several alerts a day; deliveries for different monitors are independent.
* After **10 consecutive failures** the contact is switched off and you are
  told by e-mail (or Telegram). Fix the endpoint and use **Send test**: a
  delivered test switches the contact back on and resets the counter.
* Deliveries are not queued while a contact is off — alerts from that period are
  not re-sent. The records themselves are always in the dashboard.

## Slack and Discord

Slack and Discord contacts are webhooks too, but their payloads are the
services' own formats (Block Kit and embeds) and they are not signed — the
webhook URL itself is the credential. Keep those URLs private; a leaked one can
be revoked in Slack or Discord and the contact re-added.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.