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.
Overview
Section titled “Overview”When you submit a scan with a webhook_url, FileSafety POSTs the result to that URL once scanning finishes. Your endpoint needs to:
- Accept POST requests with a JSON body
- Return a
200status code quickly - Process the result asynchronously
- Handle duplicate deliveries gracefully
Setting up the endpoint
Section titled “Setting up the endpoint”Express.js implementation
Section titled “Express.js implementation”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");});Responding to webhooks
Section titled “Responding to webhooks”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 afterapp.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");});Retry behavior
Section titled “Retry behavior”If your endpoint returns a non-2xx status or the connection fails, FileSafety retries up to 3 times with exponential backoff:
| Attempt | Delay |
|---|---|
| 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}.
Idempotency
Section titled “Idempotency”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.
In-memory (development)
Section titled “In-memory (development)”const processedScans = new Set();
if (processedScans.has(payload.scan_id)) { return; // Already handled}processedScans.add(payload.scan_id);Database-backed (production)
Section titled “Database-backed (production)”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] );}Using metadata for routing
Section titled “Using metadata for routing”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:
When submitting the scan
Section titled “When submitting the scan”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", }));In the webhook handler
Section titled “In the webhook handler”function handleScanResult(payload) { const { user_id, upload_id, context } = payload.metadata;
if (context === "profile-picture" && payload.verdict !== "clean") { rejectProfilePicture(user_id, upload_id); }}Security considerations
Section titled “Security considerations”Validate scan IDs
Section titled “Validate scan IDs”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 scanpendingScans.add(scan.scan_id);
// In webhook handlerif (!pendingScans.has(payload.scan_id)) { console.warn(`Unknown scan_id: ${payload.scan_id}`); return;}pendingScans.delete(payload.scan_id);Use HTTPS
Section titled “Use HTTPS”Always use an HTTPS URL for your webhook endpoint. FileSafety requires HTTPS for webhook URLs.
Keep the URL secret
Section titled “Keep the URL secret”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=a8f3k9d2m1p4Validate the token in your handler before processing.
Testing webhooks locally
Section titled “Testing webhooks locally”During development, use a tunneling tool to expose your local server:
# Using ngrokngrok http 3000Then use the ngrok URL as your webhook_url:
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"See also
Section titled “See also”- Webhooks API Reference — Payload schema and field descriptions
- Code Examples — Complete webhook handlers in Node.js and Python