Webhooks
Webhooks allow you to receive real-time HTTP notifications when incident events occur, eliminating the need to poll the API for updates.
Overview
When you register a webhook subscription, PhishFort will send an HTTP POST request to your specified URL whenever a subscribed event occurs. Each delivery includes an HMAC-SHA256 signature for verification.
Registering a Webhook
POST /webhooks
curl -X POST https://capi.phishfort.com/v1/webhooks \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/phishfort",
"events": ["incident.status_changed", "incident.history_created"],
"description": "Production webhook"
}'
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | HTTPS URL to receive webhook deliveries |
events |
string[] | Yes | Array of event types to subscribe to |
description |
string | No | Optional label for the subscription |
Response (201):
{
"data": {
"id": "abc123",
"clientId": "your-client-id",
"url": "https://example.com/webhooks/phishfort",
"secret": "a1b2c3d4e5f6...your-signing-secret",
"events": ["incident.status_changed", "incident.history_created"],
"active": true,
"description": "Production webhook",
"createdAt": "2026-03-11T14:30:00.000Z",
"message": "Webhook subscription created successfully. Save the secret — it will not be shown again."
},
"message": "Success"
}
⚠️ Save the secret value immediately — it is only returned once at creation time and is required for signature verification.
Event Types
| Event | Trigger | Description |
|---|---|---|
incident.created |
New incident reported | Fired when a new incident is created |
incident.status_changed |
Status transitions | Fired when an incident's status changes (e.g., pending_review → blocklisted → takedown_in_progress) |
incident.history_created |
New comment/message | Fired when a client-visible comment or update is added to an incident |
incident.takedown_updated |
Takedown initiated | Fired when a takedown is initiated or re-initiated |
incident.action_required |
Client action needed | Fired when client action is requested (e.g., additional evidence needed) |
Payload Format
Each webhook delivery sends a JSON POST request with this structure:
{
"id": "evt_550e8400-e29b-41d4-a716-446655440000",
"event": "incident.status_changed",
"timestamp": "2026-03-11T14:30:00.000Z",
"data": {
"incidentId": "abc123",
"clientId": "your-client-id",
"safeDomain": "example.com",
"url": "https://phishing-example.com/login",
"incidentType": "domain",
"domain": "phishing-example.com",
"source": "CLIENT_REPORTED",
"status": "takedown_in_progress",
"incidentClass": "phishing",
"reportedBy": "user@example.com",
"timestamp": "2026-03-10T08:00:00.000Z",
"lastHistoryUpdateTimestamp": "2026-03-11T14:00:00.000Z",
"burnStartedTimestamp": "2026-03-11T14:30:00.000Z"
}
}
The envelope fields (id, event, timestamp, and data) are stable. The data object represents the current incident state, but fields that do not apply or have not been populated can be omitted. Consumers must ignore unknown fields and tolerate missing optional fields.
Data Fields
| Field | Type | Description |
|---|---|---|
incidentId |
string | Unique incident identifier |
clientId |
string | Your client identifier |
safeDomain |
string | Protected or associated domain when available; do not use it for event correlation |
url |
string | The phishing/malicious URL when the incident has one |
subject |
string | Observable value when provided, including email addresses, phone numbers, and IPv4 addresses |
incidentType |
string | Incident/observable type such as domain, email, phone, ipv4, or social |
domain |
string | Domain extracted from a URL or domain incident |
source |
string | CLIENT_REPORTED or PHISHFORT_DETECTED |
status |
string | Verbose status (see table below) |
incidentClass |
string | Classification (e.g., phishing, malware, n/a) |
reportedBy |
string | Who reported the incident |
waitForClient |
boolean or string | true when client action is required, or a reason string when one is available |
timestamp |
string | When the incident was created (ISO 8601) |
lastHistoryUpdateTimestamp |
string | When the last history update occurred (ISO 8601) |
burnStartedTimestamp |
string | When takedown was initiated (ISO 8601) |
takedownTimestamp |
string | When takedown succeeded (ISO 8601) |
historyEntry |
object | For history events, the client-visible message and its type |
Except for incidentId, treat the fields above as optional in your decoder. During an incident's lifetime, a field may be absent and appear in a later event.
Status Values
The status field uses verbose status values that reflect the incident's current state:
| Status | Description |
|---|---|
pending_review |
Incident is awaiting review |
case_building |
Case is being built for takedown |
approval_required |
Takedown requires client approval |
takedown_ready |
Takedown approved and ready to execute |
pre_weaponised |
Incident detected but not yet active |
blocklisted |
Reported to partner blocklists |
takedown_in_progress |
Takedown is actively being processed |
takedown_success |
Takedown completed successfully |
takedown_attempt_failed |
Takedown attempt was unsuccessful |
action_required |
Client action is needed |
closed |
Incident has been closed |
Signature Verification
Every webhook delivery includes these headers:
| Header | Description |
|---|---|
X-PhishFort-Signature |
sha256=<hex_hmac> |
X-PhishFort-Event |
Event type (e.g., incident.status_changed) |
X-PhishFort-Delivery-Id |
Unique HTTP delivery-attempt ID |
X-PhishFort-Timestamp |
Unix timestamp (seconds) |
How to Verify
The signature is computed as HMAC-SHA256(secret, timestamp + "." + raw_request_body). Verify the unmodified bytes before parsing JSON. Re-serializing a parsed object can change whitespace or property ordering and invalidate the signature. In the examples below, replace webhookQueue / webhook_queue with your durable queue or event-store client.
const crypto = require("crypto");
const express = require("express");
const app = express();
// Capture bytes before express.json() parses the body.
app.use(express.json({
verify: (req, _res, buffer) => {
req.rawBody = Buffer.from(buffer);
},
}));
function hasValidSignature(secret, signatureHeader, timestampHeader, rawBody) {
if (
typeof signatureHeader !== "string" ||
typeof timestampHeader !== "string" ||
!Buffer.isBuffer(rawBody) ||
!signatureHeader.startsWith("sha256=")
) {
return false;
}
const timestamp = Number(timestampHeader);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > 300) {
return false;
}
const hex = signatureHeader.slice("sha256=".length);
if (!/^[0-9a-f]{64}$/i.test(hex)) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestampHeader}.`)
.update(rawBody)
.digest();
const received = Buffer.from(hex, "hex");
return crypto.timingSafeEqual(expected, received);
}
app.post("/webhooks/phishfort", async (req, res) => {
const valid = hasValidSignature(
process.env.PHISHFORT_WEBHOOK_SECRET,
req.get("X-PhishFort-Signature"),
req.get("X-PhishFort-Timestamp"),
req.rawBody,
);
if (!valid) return res.status(401).json({ error: "Invalid signature" });
// Persist or enqueue before acknowledging. Do not do slow downstream work here.
await webhookQueue.send(req.body);
return res.status(204).end();
});
import hmac
import hashlib
import time
def has_valid_signature(secret, signature_header, timestamp_header, raw_body):
if not signature_header or not signature_header.startswith("sha256="):
return False
try:
timestamp = int(timestamp_header)
received = bytes.fromhex(signature_header.removeprefix("sha256="))
except (TypeError, ValueError):
return False
if abs(int(time.time()) - timestamp) > 300:
return False
message = timestamp_header.encode() + b"." + raw_body
expected = hmac.new(
secret.encode(),
message,
hashlib.sha256,
).digest()
return hmac.compare_digest(expected, received)
@app.route("/webhooks/phishfort", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-PhishFort-Signature")
timestamp = request.headers.get("X-PhishFort-Timestamp")
raw_body = request.get_data(cache=True)
if not has_valid_signature(
WEBHOOK_SECRET, signature, timestamp, raw_body
):
return {"error": "Invalid signature"}, 401
webhook_queue.send(request.get_json())
return "", 204
Replay protection and durable acknowledgement
Reject timestamps more than five minutes in either direction from your current clock. Return 2xx only after the event is durably stored or queued, and finish within the five-second delivery timeout.
X-PhishFort-Delivery-Id identifies one HTTP attempt. A retry can have a different delivery ID, so do not use that header as the only business-idempotency key. Make the downstream operation naturally idempotent or store a durable fingerprint of the logical event (for example, a canonical hash of event and data) for a bounded retention period.
Retry Policy
If your endpoint returns a non-2xx status or times out (5 second limit), PhishFort will retry delivery with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
After the fifth failure, retries for that event stop and lastDeliveryStatus is failed. The subscription remains active; a later incident event will still be attempted.
Managing Webhooks
List Webhooks
GET /webhooks
Update Webhook
PATCH /webhooks/:id
curl -X PATCH https://capi.phishfort.com/v1/webhooks/WEBHOOK_ID \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"active": false,
"events": ["incident.status_changed"]
}'
| Field | Type | Description |
|---|---|---|
url |
string | New HTTPS delivery URL |
events |
string[] | Updated event subscriptions |
active |
boolean | Enable/disable the subscription |
description |
string | Updated label |
Delete Webhook
DELETE /webhooks/:id
Send Test Event
POST /webhooks/:id/test
Sends a test event to your webhook URL to verify it's working correctly.
Rotate Signing Secret
POST /webhooks/:id/rotate-secret
curl -X POST https://capi.phishfort.com/v1/webhooks/WEBHOOK_ID/rotate-secret \
-H "x-api-key: YOUR_API_KEY"
Generates a new signing secret for the webhook. The previous secret is invalidated immediately.
Response (200):
{
"data": {
"secret": "new-signing-secret-hex-string",
"message": "Secret rotated successfully. Save the new secret — it will not be shown again."
},
"message": "Success"
}
⚠️ Update your endpoint immediately — deliveries signed with the old secret will fail verification after rotation.
Troubleshooting
Webhook deliveries failing?
- Ensure your endpoint returns a
2xxresponse within 5 seconds - Verify your URL is HTTPS and publicly accessible
- Persist or enqueue before acknowledging, then do downstream work asynchronously
- Use the test endpoint to confirm connectivity
Signature verification failing?
- Ensure you're using the raw JSON body (not a parsed/re-serialized version)
- Check that you're using the correct signing secret
- Verify the timestamp is being read from the
X-PhishFort-Timestampheader - Sign
timestamp + "." + raw request body, without parsing and re-serializing it
Subscription marked as failed?
After five failed attempts, the subscription's lastDeliveryStatus is set to failed and retries for that event stop. The subscription remains active — future events will still attempt delivery. Use the test endpoint to verify your fix.
Limits
- Maximum 5 webhook subscriptions per client
- Delivery timeout: 5 seconds per attempt
- Maximum 5 retry attempts per delivery