Skip to content

Webhook Integration

Webhooks let you receive scan results automatically when processing completes, without polling the API. This guide walks through building a production-ready webhook receiver.

When you submit a scan with a webhook_url, FileSafety POSTs the result to that URL once scanning finishes. Your endpoint needs to:

  1. Accept POST requests with a JSON body
  2. Return a 200 status code quickly
  3. Process the result asynchronously
  4. Handle duplicate deliveries gracefully
import express from "express";
const app = express();
app.use(express.json());
const processedScans = new Set();
app.post("/webhooks/filesafety", async (req, res) => {
// Respond immediately — FileSafety expects a 2xx within a few seconds
res.status(200).json({ received: true });
const payload = req.body;
const { scan_id, verdict, virus, nsfw, metadata, file_hash } = payload;
// Idempotency: skip if already processed
if (processedScans.has(scan_id)) {
return;
}
processedScans.add(scan_id);
try {
await handleScanResult(payload);
} catch (err) {
console.error(`Failed to process webhook for ${scan_id}:`, err);
}
});
async function handleScanResult(payload) {
const { scan_id, verdict, virus, nsfw, metadata } = payload;
switch (verdict) {
case "clean":
// File is safe — allow access, move to permanent storage, etc.
await markFileApproved(metadata.upload_id);
break;
case "infected":
// Malware detected — quarantine or delete the file
await quarantineFile(metadata.upload_id, virus.signature);
await notifyUser(metadata.user_id, `File rejected: malware detected (${virus.signature})`);
break;
case "nsfw":
// Inappropriate content — flag for review or reject
await flagForReview(metadata.upload_id, nsfw.categories);
break;
case "mixed":
// Both virus and NSFW — treat as the more severe case
await quarantineFile(metadata.upload_id, virus.signature);
break;
case "failed":
// Scan engine error — resubmit the scan
await retryScan(metadata.upload_id);
break;
}
}
app.listen(3000, () => {
console.log("Webhook server listening on port 3000");
});

FileSafety expects your endpoint to return a 2xx status code (200, 201, 202, 204). The response body is ignored.

Return 200 before processing. If your processing logic takes more than a few seconds, the request may time out and trigger a retry. Always acknowledge receipt first, then handle the payload asynchronously.

// Good: respond first, process after
app.post("/webhooks/filesafety", (req, res) => {
res.status(200).send("ok");
processAsync(req.body);
});
// Bad: process first, then respond (may time out)
app.post("/webhooks/filesafety", async (req, res) => {
await processAsync(req.body); // This could take seconds
res.status(200).send("ok");
});

If your endpoint returns a non-2xx status or the connection fails, FileSafety retries up to 3 times with exponential backoff:

AttemptDelay
1st retry~10 seconds
2nd retry~1 minute
3rd retry~5 minutes

After all retries are exhausted, the webhook is abandoned. The scan result remains available via GET /v1/scan/{id}.

Because of retries and network conditions, your endpoint may receive the same webhook more than once. Use the scan_id to detect and skip duplicates.

const processedScans = new Set();
if (processedScans.has(payload.scan_id)) {
return; // Already handled
}
processedScans.add(payload.scan_id);

For production, use a persistent store to track processed scan IDs:

async function isProcessed(scanId) {
const row = await db.query("SELECT 1 FROM processed_webhooks WHERE scan_id = $1", [scanId]);
return row.length > 0;
}
async function markProcessed(scanId) {
await db.query(
"INSERT INTO processed_webhooks (scan_id, processed_at) VALUES ($1, NOW()) ON CONFLICT DO NOTHING",
[scanId]
);
}

Include contextual information in the metadata field when submitting scans. This data is returned in the webhook payload, allowing you to route results without maintaining a scan-to-context mapping:

const formData = new FormData();
formData.append("file", fileBlob, "avatar.png");
formData.append("webhook_url", "https://your-app.com/webhooks/filesafety");
formData.append(
"metadata",
JSON.stringify({
user_id: "usr_123",
upload_id: "upl_456",
context: "profile-picture",
})
);
function handleScanResult(payload) {
const { user_id, upload_id, context } = payload.metadata;
if (context === "profile-picture" && payload.verdict !== "clean") {
rejectProfilePicture(user_id, upload_id);
}
}

Verify that the scan_id in the webhook corresponds to a scan you actually submitted. Maintain a set of pending scan IDs and check against it:

const pendingScans = new Set();
// When submitting a scan
pendingScans.add(scan.scan_id);
// In webhook handler
if (!pendingScans.has(payload.scan_id)) {
console.warn(`Unknown scan_id: ${payload.scan_id}`);
return;
}
pendingScans.delete(payload.scan_id);

Always use an HTTPS URL for your webhook endpoint. FileSafety requires HTTPS for webhook URLs.

Your webhook URL is effectively an unauthenticated endpoint. Avoid using predictable paths and consider adding a random token to the URL:

https://your-app.com/webhooks/filesafety?token=a8f3k9d2m1p4

Validate the token in your handler before processing.

During development, use a tunneling tool to expose your local server:

Terminal window
# Using ngrok
ngrok http 3000

Then use the ngrok URL as your webhook_url:

Terminal window
curl -X POST https://api.filesafety.dev/v1/scan \
-H "x-api-key: $FILESAFETY_API_KEY" \
-F "file=@./test.pdf" \
-F "webhook_url=https://abc123.ngrok.io/webhooks/filesafety"