Security alert webhooks
Security alerts reach people by email. To reach your incident or ticket system as well, subscribe an outbound webhook destination to one or more security events. Each event is one signed HTTPS POST, retried until your receiver accepts it, and recorded in the delivery log.
Two body formats are available per destination:
- Generic (default): the fremforge JSON event described below.
- ServiceNow: an incident record that ServiceNow’s Table API accepts as it is. See ServiceNow.
Events
| Event | Sent when | Severity |
|---|---|---|
security.finding.created | A new open finding appears: dependency or container vulnerability, SAST, secret, licence policy | critical, or high if you choose it |
security.malware.matched | Malware in an upload (ClamAV), or a known-malicious package (OSV MAL-*) in a default-branch or image SBOM | critical |
security.pull.blocked | A container or package pull was refused by the malware gate or your image-scan block_pull policy | high |
security.policy.violation | An authentication-policy violation (token lifetime, hardware key, SSH certificate, deploy key), or a push rejected by push protection | high |
security.exception.expired | A dismissal (risk acceptance) with an expiry date lapsed and the finding is open again | none |
Two more event names reach a subscribed destination without being subscribed to:
security.summary: sent after an hour in which the hourly cap held events back.security.test: sent by the test button or the test endpoint.
Security events are opt-in by name. A destination with an empty event list receives every Forgejo, status and maintenance event, but no security events. List the security events you want explicitly.
A subscription starts when you make it. Findings that existed before you subscribed are not replayed. Use the findings API for the current backlog.
One event per finding. A rescan does not send a finding again. Its correlation_key stays the same for every event about it, including the security.exception.expired event when a risk acceptance on it lapses.
Severity. security.finding.created is sent for critical findings by default. Set security_min_severity to high to receive high findings too. SAST errors count as high. Secrets count as critical, and so do forbidden licences. Restricted licences count as high. The other events are always sent.
Body
{
"event": "security.finding.created",
"event_id": "3f0c2a9e5b1d4c7a8e6f1b2c3d4e5f60",
"occurred_at": "2026-10-04T10:12:44.000Z",
"org": "acme",
"severity": "critical",
"title": "CVE-2026-12345 in lodash 4.17.20",
"repository": "acme/web",
"source": "dep",
"correlation_key": "dep:acme/web:CVE-2026-12345:npm:lodash@4.17.20",
"console_url": "https://frem.sh/acme/_admin/code-security/deps",
"details": {
"finding_id": "8b8d2c7e-0f6a-4a51-9a63-0c1f6f0b2a11",
"vulnerability_id": "CVE-2026-12345",
"ecosystem": "npm",
"package": "lodash",
"version": "4.17.20",
"fixed_version": "4.17.21",
"manifest_path": "package-lock.json",
"cvss_score": 9.8
}
}| Field | Meaning |
|---|---|
event | One of the event names above |
event_id | Stable per event. Use it to discard a delivery you already processed |
occurred_at | When the finding, refusal or violation happened (UTC) |
severity | critical, high or null |
source | What produced it: dep, image, sast, secret, license, malware, dependency_malware, push_protection, auth_policy, registry, or a scanner name for an expiry |
correlation_key | The finding’s identity. The same key is used for every event about the same finding |
console_url | The console page that shows it |
details | Event-specific fields. Secret values are never included: a secret finding carries the rule, file and line only |
Signature and headers
Every request carries:
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: fremforge-webhook/1
X-Fremforge-Event: security.finding.created
X-Fremforge-Delivery: ffm-attempt-6d0f…
X-Fremforge-Timestamp: 1791108764
X-Fremforge-Signature: t=1791108764,v1=5d41402abc4b2a76b9719d911017c592…v1 is the hex HMAC-SHA256, keyed with the destination’s signing secret, over the timestamp, a dot and the raw body: <t>.<body>. Verify it over the bytes you received, before parsing them. Reject a timestamp more than a few minutes old. After a secret rotation, X-Fremforge-Signature-Previous carries the same signature made with the old secret for 7 days.
import hmac, hashlib, time
def verify(headers, raw_body: bytes, secret: str) -> None:
parts = dict(p.split('=', 1) for p in headers['X-Fremforge-Signature'].split(','))
ts, sig = parts['t'], parts['v1']
if abs(time.time() - int(ts)) > 300:
raise ValueError('stale')
expected = hmac.new(secret.encode(), ts.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise ValueError('bad signature')A destination can also carry one extra header, for receivers that authenticate by header rather than by signature. ServiceNow is one: Authorization: Basic …. The value is stored encrypted and is never shown again. Headers that would break the request (Host, Content-Type, X-Fremforge-*, proxy headers and similar) are refused.
Delivery
- Retries: a non-
2xxanswer, a timeout or a connection error is retried after 2 minutes, then 5 and 15 minutes, 1 hour and 6 hours. That makes six attempts in all over about 7.5 hours. After the sixth the delivery is markeddlqand stays in the log. - Target: a public HTTPS URL. Private, loopback, link-local and metadata addresses are refused. Requests leave through fremforge’s outbound proxy.
- Health: after five failed deliveries in a row, the org’s billing contacts get an email naming the destination.
- Log: every attempt, with its status, HTTP answer and error, is in
GET /orgs/{org}/webhooks/deliveries?destination_id={id}.
Hourly cap
Each destination accepts at most security_hourly_cap security events per clock hour (UTC). The default is 100 and the maximum 1000. Events past the cap are not delivered; they appear in the delivery log as suppressed. After the hour, one security.summary event says how many were held back, per event name:
{
"event": "security.summary",
"title": "240 security event(s) held back by the hourly cap (2026-10-04T10:00 UTC)",
"details": {
"hour_start": "2026-10-04T10:00:00.000Z",
"suppressed_total": 240,
"suppressed_by_event": { "security.finding.created": 240 }
}
}The cap protects your ticket system from a first scan of a large monorepo. The findings themselves are all in the console and the findings API.
Set it up in the console
In your organisation’s admin, open Webhooks (https://frem.sh/<org>/_admin/webhooks). Add a destination, or choose the ServiceNow incidents quick start. Then, under Security events on the destination:
- tick the events to send;
- choose Findings to send: Critical only or Critical and high;
- set the Hourly limit;
- choose the Body format: Generic JSON (signed) or ServiceNow incident (Table API), with the optional ServiceNow incident fields;
- optionally set an Auth header.
Send test queues a security.test event and shows whether it was delivered and what the receiver answered. A destination that already receives all repository events (an empty event list) cannot be narrowed to security events. Create a separate destination for them.
Set it up with the API
Scope webhooks:write. Create a destination subscribed to critical and high findings and malware:
curl -sS -X POST "https://frem.sh/_app/api/v1/orgs/acme/webhooks/destinations" \
-H "Authorization: Bearer $FREMFORGE_TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "SOC intake",
"target_url": "https://soc.example.com/hooks/fremforge",
"events": ["security.finding.created", "security.malware.matched", "security.pull.blocked"],
"security_min_severity": "high",
"security_hourly_cap": 200
}'The answer contains the signing secret once. Store it with your receiver.
Change any setting later with PATCH /orgs/{org}/webhooks/destinations/{id}: events, payload_format, security_min_severity, security_hourly_cap, servicenow, auth_header, name, target_url or active. "auth_header": null removes the header.
Send a test event:
curl -sS -X POST "https://frem.sh/_app/api/v1/orgs/acme/webhooks/destinations/$ID/test" \
-H "Authorization: Bearer $FREMFORGE_TOKEN"
# {"id":"…","attempt_id":"…","event":"security.test","status":"queued"}
curl -sS "https://frem.sh/_app/api/v1/orgs/acme/webhooks/deliveries?destination_id=$ID&limit=5" \
-H "Authorization: Bearer $FREMFORGE_TOKEN"The test goes through the same path as a real event: the destination’s format, signature, auth header, proxy and retries. The endpoint answers 202. A paused destination ("active": false) answers 409, so resume it first.
Related
- ServiceNow: incidents from security events.
- SIEM forwarding: the whole audit log, for detection rules rather than tickets.
- Webhooks: repository events from Forgejo.