Skip to content

Webhooks#

Webhooks allow you to receive HTTP notifications when incident events occur, eliminating the need to poll the API for updates. Events are emitted once the underlying change has been processed — typically within a couple of minutes of the report, status change, or comment — rather than synchronously with the API call that caused it.

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. See Incident Types for the complete vocabulary, including application and browser-extension types.
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 Broad classification of the threat: phishing, malware, scam, infringement, brandabuse or other. See Incident Class. Not the same vocabulary as incidentType.
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. The test delivery uses "event": "webhook.test" and a synthetic data.incidentId (prefixed test_) together with your clientId; consumers should acknowledge it and must not look up or act on that identifier. Note that a successful test only proves connectivity and signature verification for that subscription — confirm end-to-end delivery with a real incident on the client the subscription belongs to.

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