Skip to content

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_reviewblocklistedtakedown_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

curl https://capi.phishfort.com/v1/webhooks \
  -H "x-api-key: YOUR_API_KEY"

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

curl -X DELETE https://capi.phishfort.com/v1/webhooks/WEBHOOK_ID \
  -H "x-api-key: YOUR_API_KEY"

Send Test Event

POST /webhooks/:id/test

curl -X POST https://capi.phishfort.com/v1/webhooks/WEBHOOK_ID/test \
  -H "x-api-key: YOUR_API_KEY"

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?

  1. Ensure your endpoint returns a 2xx response within 5 seconds
  2. Verify your URL is HTTPS and publicly accessible
  3. Use the test endpoint to confirm connectivity

Signature verification failing?

  1. Ensure you're using the raw JSON body (not a parsed/re-serialized version)
  2. Check that you're using the correct signing secret
  3. Verify the timestamp is being read from the X-PhishFort-Timestamp header
  4. 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