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",
    "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

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. Persist or enqueue before acknowledging, then do downstream work asynchronously
  4. 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. 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