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 specification, so the published Standard Webhooks libraries verify every delivery.
1. Connect an endpoint
In FortWatch, open Integrations → Alerts → Webhook (open it) 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
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-idnames the delivery. It stays the same on every retry and on a resend, so drop a delivery whose id you have already handled.idin 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-timestampis 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):
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 wrongPython (pip install standardwebhooks):
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["FORTWATCH_WEBHOOK_SECRET"])
event = wh.verify(raw_body, request.headers) # raises when wrongGo:
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 wrongAny language (PHP shown):
$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:
{
"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.
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
- Zap 1: Catch Hook → a filter on
typeequalsissue.opened→ Create an item in your tracker (Jira, Linear, Asana, Trello, monday.com…). Putdata.issue.idin the item, for example in its title or a field. - Zap 2: Catch Hook (the same URL works) → a filter on
typeequalsissue.resolved→ Find the item bydata.issue.id→ Close it. - Optional: when someone closes the item, have the Zap call
POST https://api.fortwatch.ai/app/scanswith the issue id and an API key, 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.