PatchFirst / Documentation
Webhook notifications
Paid monitoring supports one HTTPS destination per fleet. PatchFirst sends selected events with POST and Content-Type: application/json. Full findings and recommendations remain in your dashboard.
Enable notifications
- Open Notifications in the dashboard, select a fleet with active paid monitoring and choose Webhook.
- Enter a public HTTPS URL. The default port is 443; specify any other port explicitly. Do not include secrets or personal data in the URL.
- Select events and the number of hours without inventory before a silent-server notification.
- Save the service-generated secret in your receiver's secure storage. It is shown once. Select Send test to check your integration.
When rotating the secret, update your receiver: the old secret stops working. Changing settings cancels pending deliveries; an attempt already in progress may still complete. Saving unchanged settings preserves pending retries and silent-server notification state. Configuration and sending require active paid access.
Events
| Event | Trigger |
|---|---|
finding.new | A new P0 or P1 finding: the first appearance of a component on a server or a new CVE for it. |
finding.escalated | An existing finding's level increases to P0 or P1. |
host.kev | A finding gains a CVE that itself belongs to the KEV catalog. |
host.rating_dropped | A server's letter rating gets worse compared with its last known letter, including transitions through “?”. |
host.inventory_stale | No inventory has arrived for longer than the configured number of hours. |
webhook.test | The owner selects Send test. |
Events originate from inventory submissions or recalculation against updated feeds. Recalculating unchanged state does not create another event. Retrying delivery of an event preserves its id.
A finding's key is server + component/package. One finding.new lists the finding's main CVEs; it is sent on first appearance or when a new CVE affects the component. A version change without new CVEs or reordering the main CVEs does not create an event. The full CVE set is tracked, including CVEs beyond the top three. An existing finding's level increase is sent as finding.escalated. A decrease and return to the same level within 24 hours does not send a repeat notification for that level. A finding that disappears and returns after at least 24 hours triggers finding.new. Enabling webhooks or resuming payment initializes history from the current state without sending notifications; explicit test delivery is available separately. History keeps updating while webhooks are disabled or unpaid. New findings and level increases have separate event settings in the dashboard.
host.kev is sent only for CVEs with their own KEV flag, once per pair “server, CVE”, even when the CVE appears in several findings. A finding's overall KEV flag does not apply to its other CVEs. For a B → ? → F rating transition, the event contains before: B and after: F: “?” preserves the last known letter.
JSON payload
{
"event": "finding.new",
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"created": 1792756800,
"fleet": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"host": {
"id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
"name": "acme-1"
},
"summary": {
"cves": ["CVE-2026-12345", "CVE-2026-12346"],
"level": "P0",
"component": "acme",
"version": "1.2.3"
},
"url": "/app/#servers"
}
idis the event UUID and matchesX-PatchFirst-Delivery;createdis the creation time as integer Unix UTC seconds;fleetis the fleet UUID.hostcontains the server UUID, or isnullfor a test.nameis included only for an openly stored alias. Encrypted names are never disclosed or sent. An open alias is read at delivery time and may change between retries; the queue keeps only the UUID.summaryis a minimal event-specific object. Forfinding.newandfinding.escalated:cves(a list of up to three main CVEs),level,componentandversion(the installed version). Forhost.kev:cve(one CVE from KEV),level,componentandversion. When the installed version is unknown,versionis an empty string. For rating changes:beforeandafter; for absent inventory:hoursandlast_received(Unix UTC seconds); for tests:messagewith the valuePatchFirst webhook test. Receivers should tolerate new fields.urlis a relative dashboard link on this site; login is required. Full inventory, the names dictionary, tokens and customer data are excluded.
Request signature
Check all four headers before processing:
X-PatchFirst-Timestamp: integer Unix UTC seconds for the current attempt.X-PatchFirst-Signature:sha256=<hex>, the HMAC-SHA256 oftimestamp + "." + body. Encode the timestamp as ASCII; use the original HTTP body bytes and the exact dashboard secret as UTF-8. Do not base64-decode the secret.X-PatchFirst-Event: event type, which must matcheventin the authenticated JSON.X-PatchFirst-Delivery: delivery UUID, which must match the signedid. It remains stable across retries.
Reject timestamps more than 300 seconds before or after the receiver's clock and compare signatures in constant time. Keep clocks synchronized. Verify the original bytes before parsing JSON: serialization, whitespace and Unicode changes invalidate the signature. Each retry has a fresh timestamp and signature; created stays unchanged.
Python 3
Download verify_webhook.py. Standard library only. Pass the original request body as bytes and the headers to receive_webhook.
Show runnable Python example
"""PatchFirst receiver example (Python 3, standard library only).
Pass the original request bytes, before JSON parsing. The secret is the exact
UTF-8 string shown in the dashboard; do not base64-decode it.
"""
import hashlib
import hmac
import json
import re
import time
def verify_webhook(secret, body, headers, *, now=None):
"""Return the authenticated event or raise ValueError. No side effects."""
headers = {key.lower(): value for key, value in headers.items()}
timestamp = headers.get('x-patchfirst-timestamp', '')
signature = headers.get('x-patchfirst-signature', '')
if not re.fullmatch(r'[0-9]{1,12}', timestamp):
raise ValueError('Invalid timestamp')
if abs((time.time() if now is None else now) - int(timestamp)) > 300:
raise ValueError('Expired timestamp')
if not re.fullmatch(r'sha256=[0-9a-f]{64}', signature):
raise ValueError('Invalid signature format')
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'), timestamp.encode('ascii') + b'.' + body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError('Invalid signature')
event = json.loads(body)
if not isinstance(event, dict) or not isinstance(event.get('id'), str) or not event['id']:
raise ValueError('Missing delivery id')
if event['id'] != headers.get('x-patchfirst-delivery'):
raise ValueError('Delivery id mismatch')
if event.get('event') != headers.get('x-patchfirst-event'):
raise ValueError('Event mismatch')
return event
def receive_webhook(secret, body, headers, enqueue_unique, *, now=None):
"""Verify, then persist once. Return True for new, False for duplicate.
enqueue_unique(id, event) MUST atomically persist the event to a durable
queue with a UNIQUE constraint on id, returning False for an existing id.
Only return HTTP 2xx after this call succeeds. Queue consumers perform
business actions transactionally or with the same idempotency key.
"""
event = verify_webhook(secret, body, headers, now=now)
return enqueue_unique(event['id'], event)
Node.js
Download verify_webhook.js. Built-in node:crypto only. Pass the original body Buffer to receiveWebhook before any JSON parsing middleware.
Show runnable Node.js example
'use strict';
// PatchFirst receiver example (Node.js, built-in modules only).
const {createHmac, timingSafeEqual} = require('node:crypto');
function verifyWebhook(secret, body, headers, now = Date.now() / 1000) {
// body must be the original Buffer, before JSON parsing.
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
const timestamp = h['x-patchfirst-timestamp'];
const signature = h['x-patchfirst-signature'];
if (typeof timestamp !== 'string' || !/^[0-9]{1,12}$/.test(timestamp)) {
throw new Error('Invalid timestamp');
}
if (Math.abs(now - Number(timestamp)) > 300) throw new Error('Expired timestamp');
if (typeof signature !== 'string' || !/^sha256=[0-9a-f]{64}$/.test(signature)) {
throw new Error('Invalid signature format');
}
// Use the exact UTF-8 secret shown in the dashboard; do not base64-decode.
const digest = createHmac('sha256', Buffer.from(secret, 'utf8'))
.update(Buffer.from(timestamp + '.', 'ascii')).update(body).digest('hex');
const expected = Buffer.from('sha256=' + digest, 'ascii');
const received = Buffer.from(signature, 'ascii');
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
throw new Error('Invalid signature');
}
const event = JSON.parse(body.toString('utf8'));
if (!event || typeof event.id !== 'string' || !event.id) throw new Error('Missing delivery id');
if (event.id !== h['x-patchfirst-delivery']) throw new Error('Delivery id mismatch');
if (event.event !== h['x-patchfirst-event']) throw new Error('Event mismatch');
return event;
}
async function receiveWebhook(secret, body, headers, enqueueUnique, now = Date.now() / 1000) {
const event = verifyWebhook(secret, body, headers, now);
// enqueueUnique MUST atomically persist to a durable queue with UNIQUE(id).
// Return false for duplicates; return HTTP 2xx only after persistence succeeds.
// Queue consumers use transactions or the same idempotency key for effects.
return await enqueueUnique(event.id, event);
}
module.exports = {verifyWebhook, receiveWebhook};
Idempotency and receiver responses
The examples take your durable queue function, enqueue_unique(id, event) / enqueueUnique(id, event). It must atomically persist the authenticated event with a unique constraint on id, returning false for an existing id. Use persistent storage rather than an in-memory set. Keep keys for at least 48 hours. Queue consumers must perform actions transactionally or use the same idempotency key.
Return 2xx after successful persistence or recognition of an already persisted duplicate. Return 4xx for invalid signatures and 5xx for persistence failures. Do not acknowledge an event before storing it durably. PatchFirst does not use the response content.
Delivery and limits
A separate worker sends notifications without delaying API requests. Different receivers are served concurrently so a slow receiver does not delay other fleets. Any 2xx marks a successful delivery. Transient network errors, TLS errors (including certificate verification) and 5xx responses trigger retries after 60, 120, 240 seconds, doubling up to 21600 seconds (6 hours). The delivery window is 86400 seconds (24 hours) from event creation; an attempt beyond that deadline is not scheduled. A 410 Gone response ends delivery immediately; other 4xx responses allow at most 3 attempts in total. A 3xx response, unsafe address or oversized response ends delivery without retries. Duplicates are possible and ordering is not guaranteed; delivery is not a guaranteed emergency response channel. Check the log and your receiver after attempts are exhausted.
A fleet's queue holds at most 1000 pending deliveries. When full, the oldest pending events are discarded with a log entry. Settings are limited to 10 saves per minute per fleet.
Redirects are never followed. Destination addresses are checked after DNS resolution and on every connection: private and reserved IPv4/IPv6, loopback, link-local, CGNAT and metadata addresses are blocked. The service’s own addresses, including its public domain and IPs, are also blocked. Localhost and nonstandard numeric, octal or hexadecimal IPv4 forms are rejected when saving. Rejected addresses show only “unavailable” in the log. Use a public receiver; a 5-second network timeout, a 65536-byte (64 KiB) response cap and per-fleet rate limits apply. A 3xx response is a failure and its destination is never visited.
Notifications shows recent deliveries with time, event and HTTP response code when available. Response bodies are not retained. Review your dashboard and receiver health regularly.