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

# Webhooks

> Send each revenue alert to your own system as a signed HTTPS request, and verify that it came from Stealth.

A webhook sends each revenue alert to another system, such as your ticketing tool, a chat bot or an internal dashboard, as an HTTPS `POST` request signed by Stealth. Owners and Administrators manage webhooks in **Integrations > Notifications**. An organization can have up to 10.

## How it works

* After every check, each incident event that passes the [organization's rules](https://docs.usestealth.dev/en/alerts/overview.md) (events, minimum severity and exceptions) and the webhook's own filter (minimum severity and events) is sent to the webhook.
* Events are `opened` (**New drop**), `escalated` (**Drop got worse**) and `resolved` (**Back to normal**). An escalation from below the webhook's minimum severity arrives as `opened`, since it is the first time the webhook hears of the incident.
* Each event is one request, and the requests to one webhook go out in order. Webhooks never wait for the daily summary.
* Hourly events come from the regular hourly check. A check requested with **Check now** can send events from the daily rules.

## Steps

**1. Prepare the endpoint**

Your endpoint must use `https://` on the default port (443) with a valid certificate, have a public host name, and answer with a `2xx` within 5 seconds. IP addresses, `localhost`, single-label names and internal suffixes such as `.local` and `.internal` are refused. A user or password in the URL is refused; a token in the path or in the query is fine.

**2. Add the webhook**

Go to **Integrations > Notifications** and choose **Add webhook**. Fill in **Name** and **Endpoint URL**, choose the **Minimum severity** (**Critical only**, **Critical and Attention** or **All severities**) and the **Events** (**New drop**, **Drop got worse**, **Back to normal**), and choose **Save**.

**3. Copy the signing secret**

Stealth shows the **Signing secret** (it starts with `whsec_`) only once. Copy it to your secret manager. To replace it later, remove the webhook and add it again.

**4. Send a test**

Choose **Send test**. Stealth posts the example below with `"test": true` and your own organization, in a single attempt with no retries. Its `incident.url` points to an incident that does not exist. The test also works on a paused webhook, and you can send one every 10 seconds. The result appears in a message and in **Deliveries**.

**5. Verify the signature in your code**

Reject any request whose signature does not match, as shown in [Verify the signature](#verify-the-signature).

### Request

```http theme={null}
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: Stealth-Webhooks/1
Stealth-Signature: t=1791554400,v1=<64 hex characters>
```

### Body (version v1)

```json payload.json theme={null}
{
  "version": "v1",
  "id": "evt_4f1c2b7e9a0d4e6b8c3a5f7d9e1b2c4a",
  "test": false,
  "event": "opened",
  "severity": "critical",
  "kind": "unit_silent",
  "occurred_at": "2026-10-09T14:00:00Z",
  "organization": {
    "id": "org_882ae4d9",
    "name": "Acme Media"
  },
  "network": {
    "code": "12345678",
    "name": "Acme Network"
  },
  "client": {
    "code": "987654",
    "name": "Example News"
  },
  "ad_unit": {
    "id": "23145678901",
    "name": "EXAMPLE_TOP",
    "format": "top"
  },
  "incident": {
    "id": "7d3f9a52-1c4e-4b8a-9f61-2e5d8c0b4a17",
    "started_at": "2026-10-09T13:00:00Z",
    "url": "https://app.usestealth.dev/console/incidents/7d3f9a52-1c4e-4b8a-9f61-2e5d8c0b4a17"
  },
  "estimated_loss": {
    "per_hour": 35.2,
    "currency": "BRL"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `version` | string | Always `v1` for this format. |
| `id` | string | Unique event id: `evt_` and 32 hex characters. It stays the same on every retry and on every webhook of the organization, so use it to discard duplicates. |
| `test` | boolean | `true` only for events sent with **Send test**. |
| `event` | string | `opened`, `escalated` or `resolved`. |
| `severity` | string | `critical`, `attention` or `light`. For `resolved`, the severity the incident had. |
| `kind` | string | `unit_silent`, `no_fill`, `client_traffic_drop`, `client_fill_drop`, `yield_drop` or `trend_down`. See [Alert kinds](https://docs.usestealth.dev/en/detection/alert-kinds.md). |
| `occurred_at` | string | When Stealth detected the event, ISO 8601 in UTC. |
| `organization` | object | The organization's short `id` and its `name`. |
| `network` | object | The Google Ad Manager network `code` and `name`. |
| `client` | object or null | The client (child publisher) `code` and `name`. `null` when the incident has no client of its own, such as on a network without partners. |
| `ad_unit` | object or null | The Google Ad Manager ad unit `id`, its `name` and its `format` (`anchor`, `intext`, `infeed`, `vignette`, `endOfArticle`, `top`, `side`, `footer`, `flex`, `video` or `other`). `null` for an incident of a whole client. |
| `incident` | object | The Stealth incident `id`, `started_at` (ISO 8601, UTC) and `url`, which opens the incident's client in the dashboard. |
| `estimated_loss` | object | `per_hour`: the estimated loss per hour in currency units, with two decimals. For `resolved`, the average per hour over the whole incident. `currency`: the ISO 4217 code of the network's currency. |

New fields can join `v1` at any time, so ignore the fields you do not know. Renaming or removing a field needs a `v2`, announced in the [changelog](https://docs.usestealth.dev/en/changelog.md).

### Verify the signature

`Stealth-Signature` is `t=<unix seconds>,v1=<hex>`, where the hex is the HMAC-SHA256 of `<t>.<raw body>`, keyed with the webhook's signing secret (the whole string, including `whsec_`).

1. Read the raw body before parsing the JSON.
2. Compute the HMAC and compare it in constant time.
3. Reject a `t` more than 5 minutes away from your clock, to block replays.

```js verify.js theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyStealthSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries((header ?? '').split(',').map((p) => p.split('=')))
  const t = Number(parts.t)
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
  const given = Buffer.from(parts.v1 ?? '', 'hex')
  return given.length === expected.length && timingSafeEqual(given, expected)
}
```

```python verify.py theme={null}
import hashlib, hmac, time

def verify_stealth_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t = parts.get("t", "")
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

### Answers, retries and pausing

* Answer with any `2xx` within 5 seconds. If your work takes longer, answer first and do it afterwards.
* Stealth tries up to 3 times, waiting 1 second and then 3 seconds, after a timeout, a network error, `408`, `429` or a `5xx`. Any other answer fails at once. Redirects are never followed, and the response body is never read.
* When a delivery fails every attempt, the other events of that check are not sent to that webhook and show as **Not sent** in its history. Failed events are not sent again later.
* Each webhook gets up to 60 seconds of every check. When that time runs out, the deliveries still waiting show as **Not sent**, with a note that the time ran out. They do not count as failures, and they are not sent again.
* A webhook is paused once it has been failing for 24 hours and at least 3 checks have failed in that time. Every member of the organization gets a notice in the app and by email, and Owners and Administrators get the link to resume it. A delivery that works, or a **Send test** that gets a `2xx`, starts the count over. Events that happen while it is paused are not kept. Choose **Resume** once the endpoint works.

## What you see

Each webhook in **Integrations > Notifications** shows its status (**Active** or **Paused**), and, when it is failing, since when. **Deliveries** shows the last 20 deliveries: **Delivered**, **Failed** or **Not sent**, with the HTTP status, attempts and duration. Stealth keeps the result, never the content. A check adds at most 10 **Not sent** rows per webhook.

The row menu has **Edit**, **Pause** or **Resume**, and **Remove**. Removing a webhook stops it at once and deletes its delivery history.

### How Stealth protects the requests it sends

A webhook makes Stealth's servers send a request to an address you choose, so Stealth limits where that request can go:

* Only `https`, only port 443, and the certificate must be valid.
* No IP addresses and no private or internal names.
* Redirects are never followed, and the response body is never read.
* The address is checked when you save it and again before every send.

Stealth does not check which IP address the host name points to when sending, and such a check could be defeated anyway by changing the name's DNS between the check and the request. A public name pointed at a private address is therefore not caught. Use only endpoints you control and whose DNS you trust. **Send test** also leaves from Stealth's servers, not from your browser, so the endpoint must be reachable from the internet.

## FAQ

> [accordion] How do I avoid processing the same event twice?
>
> Use the `id` field. It stays the same on every retry and on every webhook of the organization.

> [accordion] Can I send alerts to Slack, Microsoft Teams or Discord?
>
> Not directly. Point the webhook to a small service or an automation tool that receives the request, verifies the signature and posts the message to your chat.

> [accordion] Does the webhook include the demand source reading?
>
> No. Version `v1` carries the fields listed above. The reading appears in the dashboard and in the opening email of daily incidents.


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