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",
"subject": null,
"incidentType": "domain",
"domain": "phishing-example.com",
"source": "CLIENT_REPORTED",
"status": "takedown_in_progress",
"incidentClass": "phishing",
"reportedBy": "user@example.com",
"waitForClient": null,
"timestamp": "2026-03-10T08:00:00.000Z",
"lastHistoryUpdateTimestamp": "2026-03-11T14:00:00.000Z",
"burnStartedTimestamp": "2026-03-11T14:30:00.000Z",
"takedownTimestamp": null
}
}
The data object has the same structure for all event types — it always contains the full current incident state at the time the event was emitted.
Data Fields
| Field | Type | Description |
|---|---|---|
incidentId |
string | Unique incident identifier |
clientId |
string | Your client identifier |
safeDomain |
string | The protected domain this incident targets |
url |
string | The phishing/malicious URL (null for non-URL incident types) |
subject |
string | Email subject line (for email-type incidents) |
incidentType |
string | One of: domain, email, phone, ipv4, social |
domain |
string | The domain extracted from the incident URL |
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 |
string | Reason client action is needed (null if not waiting) |
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, null if not started) |
takedownTimestamp |
string | When takedown succeeded (ISO 8601, null if not taken down) |
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 delivery ID |
X-PhishFort-Timestamp |
Unix timestamp (seconds) |
How to Verify
The signature is computed as: HMAC-SHA256(secret, timestamp + "." + JSON.stringify(payload))
const crypto = require("crypto");
function verifyWebhookSignature(secret, signature, timestamp, body) {
const message = `${timestamp}.${JSON.stringify(body)}`;
const expected = crypto
.createHmac("sha256", secret)
.update(message)
.digest("hex");
const received = signature.replace("sha256=", "");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received),
);
}
// Express middleware example
app.post("/webhooks/phishfort", (req, res) => {
const signature = req.headers["x-phishfort-signature"];
const timestamp = req.headers["x-phishfort-timestamp"];
// Reject old deliveries (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) {
return res.status(401).json({ error: "Timestamp too old" });
}
if (!verifyWebhookSignature(WEBHOOK_SECRET, signature, timestamp, req.body)) {
return res.status(401).json({ error: "Invalid signature" });
}
// Process the event
console.log("Received event:", req.body.event);
res.status(200).json({ received: true });
});
import hmac
import hashlib
import json
import time
def verify_webhook_signature(secret, signature, timestamp, body):
message = f"{timestamp}.{json.dumps(body, separators=(',', ':'))}"
expected = hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256,
).hexdigest()
received = signature.replace("sha256=", "")
return hmac.compare_digest(expected, received)
# Flask example
@app.route("/webhooks/phishfort", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-PhishFort-Signature")
timestamp = request.headers.get("X-PhishFort-Timestamp")
# Reject old deliveries
age = int(time.time()) - int(timestamp)
if age > 300:
return {"error": "Timestamp too old"}, 401
if not verify_webhook_signature(
WEBHOOK_SECRET, signature, timestamp, request.json
):
return {"error": "Invalid signature"}, 401
print("Received event:", request.json["event"])
return {"received": True}, 200
⚠️ Replay protection: Reject deliveries with timestamps older than 5 minutes.
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 5 failed attempts, the subscription is marked as failed and retries stop.
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
- 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 - The signature format is
timestamp + "." + JSON.stringify(payload)
Subscription marked as failed?
After 5 consecutive delivery failures, the subscription's lastDeliveryStatus is set to failed and retries for that delivery 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