# Webhooks

A webhook sends each FortWatch event your routes allow to a URL of yours, as signed JSON. Use it with Zapier, Make or n8n to open a task, post a message or page someone in almost any tool, or with your own service.

FortWatch follows the [Standard Webhooks](https://www.standardwebhooks.com) specification, so the published Standard Webhooks libraries verify every delivery.

## 1. Connect an endpoint

In FortWatch, open **Integrations → Alerts → Webhook** ([open it](https://app.fortwatch.ai/integrations?tab=alerts)) and paste the URL. Owners and admins can connect one, on a plan that includes integrations.

| Tool | Where the URL comes from |
|---|---|
| Zapier | A Zap with the trigger **Webhooks by Zapier → Catch Hook** |
| Make | A **Webhooks → Custom webhook** module |
| n8n | A **Webhook** node (POST), its production URL |
| Your own service | Any HTTPS URL that answers 2xx within 15 seconds |

The URL must use HTTPS on port 443 or 8443 and reach a public address. FortWatch never follows a redirect, so paste the final URL.

FortWatch shows the endpoint's **signing secret** once (`whsec_…`). Store it with your receiver. Pasting a new URL later issues a new secret.

Then add routes, as for any alert integration: the whole workspace, a project or an asset, each with its events, a minimum severity, instant or digest delivery and quiet hours. **Send test** delivers an `integration.test` event with an example issue, so a Zap or scenario can learn the fields.

## 2. The request

```http
POST <your URL>
Content-Type: application/json
User-Agent: FortWatch-Webhooks/1.0 (+https://docs.fortwatch.ai/webhooks)
webhook-id: msg_3f1c…
webhook-timestamp: 1760443200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
X-FortWatch-Event: issue.opened

{
  "type": "issue.opened",
  "timestamp": "2026-10-22T10:00:00.000Z",
  "api_version": "2026-10-01",
  "id": "evt_01JA…",
  "workspace": { "id": 51, "name": "Acme" },
  "data": { "issue": { … }, "firstScan": false, "scan": { "id": 900 } }
}
```

- **`webhook-id`** names the delivery. It stays the same on every retry and on a resend, so drop a delivery whose id you have already handled.
- **`id`** in the body names the event. Two routes on one endpoint send one event as two deliveries, with two webhook ids and one event id.
- **`webhook-timestamp`** is the Unix time of this attempt.
- **Answer 2xx** as soon as you have the delivery. Anything else is a failure: see section 5.

## 3. Verify every delivery

The signature is HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<raw body>`, keyed with the base64 after `whsec_`. Verify against the **raw** body before parsing it, and refuse a timestamp more than 5 minutes from your clock.

**Node.js** (`npm install standardwebhooks`):

```js
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.FORTWATCH_WEBHOOK_SECRET);
const event = wh.verify(rawBody, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
}); // throws when the signature or the timestamp is wrong
```

**Python** (`pip install standardwebhooks`):

```python
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["FORTWATCH_WEBHOOK_SECRET"])
event = wh.verify(raw_body, request.headers)  # raises when wrong
```

**Go**:

```go
import standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"

wh, err := standardwebhooks.NewWebhook(os.Getenv("FORTWATCH_WEBHOOK_SECRET"))
err = wh.Verify(rawBody, r.Header) // non-nil when wrong
```

**Any language** (PHP shown):

```php
$secret = base64_decode(substr(getenv('FORTWATCH_WEBHOOK_SECRET'), 6)); // drop "whsec_"
$id = $_SERVER['HTTP_WEBHOOK_ID'];
$ts = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'];
$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$rawBody", $secret, true));
$ok = abs(time() - (int) $ts) <= 300;
$ok = $ok && in_array(true, array_map(
  fn ($s) => hash_equals("v1,$expected", $s),
  explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'])
), true);
```

The signature header may carry several signatures separated by spaces. Accept the delivery when any one matches.

## 4. Events

| `type` | `data` |
|---|---|
| `issue.opened` | `{ issue, firstScan, scan }`: `scan` is `{ id }` or null |
| `issue.reopened` | `{ issue, scan }`: `scan` is `{ id }` or null |
| `issue.resolved` | `{ issue, by, actor, scan }`: `by` is "scan", "member" or null; `actor` is `{ name }` or null |
| `issue.dismissed` | `{ issue, reason, actor }` |
| `issue.undismissed` | `{ issue, actor }` (actor null: the dismissal expired) |
| `issue.severity_changed` | `{ issue, previous: { severity } }` |
| `issue.commented` | `{ issue, comment: { id, author: { name }, body } }` |
| `issue.sla_due_soon`, `issue.sla_breached` | `{ issue }` |
| `scan.completed` | `{ scan: { id, link, startedAt, finishedAt, triggeredBy, firstScan }, asset, counts: { new, reopened, fixed, stillOpen }, topIssues: [{ id, title, severity, link }] }` (at most 10) |
| `scan.failed` | `{ scan: { id, link, startedAt, finishedAt, triggeredBy }, asset, reason }` |
| `asset.failed` | `{ asset, reason }` |
| `digest.daily`, `digest.weekly` | `{ period, openBySeverity, issues: [{ id, title, severity, link, asset, event }], more, link }` (at most 30, `more` counts the rest) |
| `alert.overflow` | `{ asset, scan, count, issues, link }`: only when the route's limit per scan is on. Webhook routes start with it off, so each issue arrives on its own |
| `integration.test` | `{ test: true, message, scope, rules, issue }`: the issue is an example with id 0 |

**The `issue` object:**

```json
{
  "id": 1234,
  "title": "TLS certificate expired",
  "severity": "critical",
  "status": "open",
  "category": "ssl",
  "description": "… at most 2,000 characters",
  "remediation": "… at most 2,000 characters",
  "firstSeenAt": "2026-10-01T08:00:00.000Z",
  "dueDate": "2026-10-15T08:00:00.000Z",
  "assignee": { "name": "Jane Doe", "email": "jane@acme.com" },
  "asset": { "id": 77, "value": "api.example.com", "name": "API", "type": "domain" },
  "project": { "id": 5, "name": "Web" },
  "link": "https://app.fortwatch.ai/issues?id=1234",
  "revision": 1760443200123
}
```

`assignee` is null when nobody is assigned. **`revision`** grows with every change of the issue: keep the highest you have seen per issue id and ignore an older delivery, because deliveries are not guaranteed to arrive in order.

**The `asset` object** of scan and asset events: `{ id, value, name, type, project: { id, name }, link }`.

A body that would pass 256 KB is sent thin instead: `{ "truncated": true, "link": … }`. Fetch the details with the [API](/api).

New fields and new event types can appear without notice; ignore what you do not know. A change that removes or renames a field gets a new `api_version`.

## 5. Failures and retries

| Your answer | What FortWatch does |
|---|---|
| 2xx | Delivered |
| 429, or 502/503/504 with `Retry-After` | Waits as asked, then tries again |
| Another 5xx, a timeout (15 s) or a network error | Tries again with backoff |
| 404 or 410 | Pauses the endpoint's routes and tells the owners. Fix the receiver or paste a new URL, then resume |
| 401 or 403 | Pauses the routes: check your receiver's authentication and its signing secret |
| A redirect (3xx) | Pauses the routes: paste the final URL |
| Another 4xx | Fails this delivery only; the next ones still go |

Retries go on for **about 24 hours** after the event, then the delivery is marked failed. Every delivery is in the endpoint's log in FortWatch for 30 days, with its status code and latency, and a failed one can be sent again from there.

FortWatch sends at most 2 deliveries to one endpoint at a time and at most 100 a minute.

## 6. Feeding another tool through Zapier

1. Zap 1: **Catch Hook** → a filter on `type` equals `issue.opened` → **Create** an item in your tracker (Jira, Linear, Asana, Trello, monday.com…). Put `data.issue.id` in the item, for example in its title or a field.
2. Zap 2: **Catch Hook** (the same URL works) → a filter on `type` equals `issue.resolved` → **Find** the item by `data.issue.id` → **Close** it.
3. Optional: when someone closes the item, have the Zap call `POST https://api.fortwatch.ai/app/scans` with the issue id and an [API key](/authentication), so FortWatch re-checks the fix.

A delivery can arrive twice (a retry after a timeout), so make step 1 look for an existing item first, or filter on `webhook-id`.
