---
title: Webhooks & Zapier
description: The exact JSON WatchFor POSTs to your webhook, the HMAC-SHA256 signature to verify it, delivery rules, and the flat Zapier payload.
canonical: https://watchfor.io/docs/notifications/webhooks
---

# Webhooks & Zapier

The exact JSON WatchFor POSTs to your webhook, the HMAC-SHA256 signature to verify it, delivery rules, and the flat Zapier payload.

A **Webhook** channel POSTs a JSON document to a URL you control for every
incident event — the way to plug WatchFor into anything without a built-in
channel: your own automation, an incident tool, a ticketing system, an AI
agent. **Zapier** is the same idea with a flat payload shaped for Zapier's
field mapper.

Both are **per-incident** channels: one request per event, never
[grouped](/docs/notifications#grouping-during-a-storm).

## Setup

### Expose an HTTPS endpoint
It must accept `POST` with a JSON body and answer with any **2xx** status
quickly — do your processing after responding. The URL has to be on the
public internet: private/loopback addresses and WatchFor's own domains are
rejected (see [guard rails](/docs/notifications#guard-rails-on-save)).

### Add the channel in WatchFor
Go to **Alerting → Channels & contacts → Add Channel → Webhook**, paste the **Webhook
URL**, and set an **HMAC Secret** (any string you generate — 32+ random
characters). The secret is stored encrypted and masked afterwards. Attach
the [contact groups](/docs/notifications/contact-groups) whose monitors should
reach this endpoint and save.

### Send a test
**⋯ → Send test** POSTs a payload with `incident.id` = `0` and a message
starting *"This is a TEST notification"* — treat `id: 0` as a test in your
handler.

## Request

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `WatchFor-AlertManager/1.0` |
| `X-WatchFor-Signature` | `sha256=<hex HMAC-SHA256 of the raw body>` — only when an HMAC Secret is set |

- **Timeout** 10 seconds per attempt. A non-2xx response or a network error
  is **retried once** after 0.5 s; after that the delivery is recorded as
  **Failed** in [notification history](/docs/notifications/history) with the
  status code.
- Deliveries are at-least-once in the edge case where your endpoint processed
  the first attempt but the response was lost — key idempotency on
  `event` + `incident.id`.
- WatchFor doesn't publish a source-IP allow-list for alert deliveries.
  Authenticate requests by verifying the **signature** instead.

## Events

| `event` | When |
| --- | --- |
| `incident.firing` | A confirmed incident opened. Sent again for each [repeat reminder](/docs/alerting#repeat-reminders) if enabled on the monitor — same `incident.id`, higher `fire_count`. |
| `incident.resolved` | The incident closed — by recovery, or administratively (rule disabled, monitor paused, resolved by a person; then `enrichment.resolve_reason` and `resolved_by` say so). |

Acknowledgements are not sent as webhook events.

## Payload

```json
{
  "event": "incident.firing",
  "timestamp": "2026-09-02T10:15:42Z",
  "incident": {
    "id": 48213,
    "rule_id": 531,
    "scheduler_id": 90417,
    "status": "firing",
    "severity": "critical",
    "message": "Alert Rule 531 triggered",
    "fire_count": 1,
    "flapping": false,
    "started_at": "2026-09-02T10:15:12Z"
  },
  "enrichment": {
    "monitor_name": "API (prod)",
    "monitor_target": "https://api.example.com/health",
    "monitor_type": "http",
    "rule_name": "Site is down",
    "root_cause": "metrics[\"http.status_code\"] >= 500",
    "prober_location": "Frankfurt",
    "incident_url": "https://watchfor.io/dashboard/organization/incidents/48213",
    "diagnosis": "Unexpected HTTP Status",
    "friendly_expected": "Status code 2xx",
    "detected_value": "503",
    "confirming_probes": ["🇩🇪 Frankfurt · Europe", "🇺🇸 Virginia · North America"],
    "rule_version": "v2 (01 Sep 2026, 14:03 UTC)"
  }
}
```

A recovery looks the same with `"event": "incident.resolved"`,
`"status": "resolved"`, an `incident.duration` such as `"4m30s"`, and
`enrichment.resolved_value` holding what the check now returns.

### Fields

**`incident`** — always present.

| Field | Type | Meaning |
| --- | --- | --- |
| `id` | integer | Incident id; `0` for a test. Use with `event` for idempotency. |
| `rule_id` | integer | Internal id of the alert rule that fired. |
| `scheduler_id` | integer | Internal id of the monitor's check job — stable for the monitor's lifetime. |
| `status` | string | `firing` or `resolved`. |
| `severity` | string | `critical` or `warning` (matches the rule). |
| `message` | string | Raw alert text. Prefer `enrichment.diagnosis` for humans. |
| `fire_count` | integer | How many times the rule re-fired during this incident. |
| `flapping` | boolean | Rapid state changes detected. |
| `started_at` | RFC 3339 | When the incident opened. |
| `duration` | string | Go-style duration (`4m30s`), present on resolve. |

**`enrichment`** — present for real incidents; every field is optional and
omitted when empty. For a channel test the whole object is absent; for a
per-monitor test only `monitor_name` is set.

| Field | Meaning |
| --- | --- |
| `monitor_name`, `monitor_target`, `monitor_type` | The monitor, its target URL/host, and its [type](/docs/monitors) (`http`, `ssl`, `dns`, …). |
| `rule_name` | Human name of the alert rule ("Site is down"). |
| `root_cause`, `root_cause_detail` | The rule expression that matched and extra detail. |
| `diagnosis` | Plain-language explanation ("Unexpected HTTP Status", "SSL Certificate Expiring"). |
| `friendly_expected`, `detected_value`, `resolved_value` | What the rule expects, what was measured, what is measured after recovery. |
| `confirming_probes` | Locations that confirmed the failure, as `"<flag> <name> · <region>"` strings. |
| `prober_location` | The reporting location. |
| `incident_url` | Deep link to the incident. |
| `resolve_reason`, `resolved_by` | Set on administrative closes only. |
| `rule_version`, `updated_rule_version` | Rule version in effect when the incident fired, and the newer version if the rule changed mid-incident. |

## Verifying the signature

Compute HMAC-SHA256 over the **raw request body** with your secret, hex-encode
it, prefix `sha256=` and compare in constant time with the
`X-WatchFor-Signature` header. Parse JSON only after the check passes.

**Node.js**

```js

  const expected =
    "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(signatureHeader ?? "");
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: app.post("/watchfor", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyWatchFor(req.body, req.get("X-WatchFor-Signature"), process.env.WATCHFOR_SECRET)) return res.sendStatus(401);
//   const payload = JSON.parse(req.body);
//   res.sendStatus(204);  // respond first, process after
// });
```

**Python**

```python

def verify_watchfor(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header or "", expected)
```

Use the raw bytes exactly as received — re-serializing the JSON changes the
bytes and breaks the signature.

## Zapier

A **Zapier** channel sends a **flat** JSON object (no nesting) to a *Webhooks
by Zapier* Catch Hook, so every value is directly mappable in later Zap steps.

### Create the Zap
In Zapier create a Zap with the trigger **Webhooks by Zapier → Catch Hook**
and copy the hook URL (`https://hooks.zapier.com/hooks/catch/…`).

### Add the channel
**Alerting → Channels & contacts → Add Channel → Zapier**, paste the **Zapier Hook URL**,
attach contact groups and save. Press **⋯ → Send test** so Zapier records a
sample request, then build the rest of the Zap from its fields.

Fields: `event`, `timestamp`, `incident_id`, `rule_id`, `scheduler_id`,
`status`, `severity`, `message`, `fire_count`, `flapping`, `started_at`,
`duration`, `target`, `probe_type`, `expression`, `job_name` (monitor name),
`diagnosis`, `friendly_expected`, `detected_value`, `resolved_value`,
`resolve_reason`, `resolved_by`, `confirming_probes` (one comma-separated
string), `rule_version`, `incident_url`. Empty optional fields are omitted.
Zapier requests are not signed; the hook URL is the shared secret.

## Also via API

Webhooks push events to you; for pulling, the same incidents are available
through the [REST API](/docs/api/incidents), the [MCP server](/docs/api/mcp)
and A2A, and delivery attempts through `GET /v1/notifications`.

---

Canonical page: https://watchfor.io/docs/notifications/webhooks · All docs: https://watchfor.io/docs · Site guide: https://watchfor.io/llms.txt
