FortWatchFortWatchDocs
Menu

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.

ToolWhere the URL comes from
ZapierA Zap with the trigger Webhooks by Zapier → Catch Hook
MakeA Webhooks → Custom webhook module
n8nA Webhook node (POST), its production URL
Your own serviceAny 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

typedata
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.

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 answerWhat FortWatch does
2xxDelivered
429, or 502/503/504 with Retry-AfterWaits as asked, then tries again
Another 5xx, a timeout (15 s) or a network errorTries again with backoff
404 or 410Pauses the endpoint's routes and tells the owners. Fix the receiver or paste a new URL, then resume
401 or 403Pauses the routes: check your receiver's authentication and its signing secret
A redirect (3xx)Pauses the routes: paste the final URL
Another 4xxFails 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, 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.