Skip to content

Webhooks

When a scan completes, FileSafety sends the result as an HTTP POST to the webhook_url you provided when submitting the scan. Webhooks are the recommended way to receive scan results in production.

  • Method: POST
  • Content-Type: application/json
  • Timing: Delivered immediately after scan processing finishes (typically 10-20 seconds after submission)
  • Target: The webhook_url specified in your POST /v1/scan request

Each scan triggers at least one webhook delivery (scan.complete). In rare cases, a follow-up scan.verdict_updated event may also be sent if post-processing analysis revises the verdict. The webhook URL is per-scan, not a global configuration — you can use different URLs for different scans.

{
"event": "scan.complete",
"scan_id": "scn_01HX7Z9K3M2N4P5Q6R7S8T9U0V",
"verdict": "clean",
"virus": {
"clean": true,
"signature": null
},
"nsfw": {
"clean": true,
"categories": [],
"confidence": 0.01
},
"text": {
"clean": true,
"toxic": { "labels": [], "maxScore": 0.0 },
"pii": { "entities": [], "count": 0 },
"sentiment": { "dominant": "NEUTRAL", "scores": {} }
},
"file_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"metadata": {},
"completed_at": "2026-03-23T12:00:00Z"
}
FieldTypeDescription
eventstringEvent type. See Event types below.
scan_idstringThe unique scan identifier.
verdictstringOverall result: clean, infected, nsfw, toxic, mixed, or failed.
virusobjectMalware detection results.
virus.cleanbooleantrue if no virus was detected.
virus.signaturestring | nullDetected threat name, or null if clean.
nsfwobjectContent analysis (images) results.
nsfw.cleanbooleantrue if no NSFW content was detected.
nsfw.categoriesstring[]Detected categories (empty array if clean).
nsfw.confidencenumberConfidence score from 0 to 1.
textobjectContent analysis (text) results. Present when text analysis was applied based on file size.
text.cleanbooleantrue if no text issues were detected.
text.toxicobjectToxicity detection with labels array and maxScore.
text.piiobjectPII detection with entities list and count.
text.sentimentobjectSentiment analysis with dominant label and scores.
file_hashstringCryptographic hash of the file, prefixed with sha256:.
metadataobjectThe metadata you provided when submitting the scan. Empty object if none was provided.
completed_atstringISO 8601 timestamp of when the scan finished.
{
"event": "scan.complete",
"scan_id": "scn_01HX7Z9K3M2N4P5Q6R7S8T9U0V",
"verdict": "clean",
"virus": {
"clean": true,
"signature": null
},
"nsfw": {
"clean": true,
"categories": [],
"confidence": 0.01
},
"text": {
"clean": true,
"toxic": { "labels": [], "maxScore": 0.0 },
"pii": { "entities": [], "count": 0 },
"sentiment": { "dominant": "NEUTRAL", "scores": {} }
},
"file_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"metadata": { "user_id": "usr_123" },
"completed_at": "2026-03-23T12:00:00Z"
}
{
"event": "scan.complete",
"scan_id": "scn_01HX8A1B2C3D4E5F6G7H8I9J0K",
"verdict": "infected",
"virus": {
"clean": false,
"signature": "Win.Trojan.Agent-123456"
},
"nsfw": {
"clean": true,
"categories": [],
"confidence": 0.02
},
"text": {
"clean": true,
"toxic": { "labels": [], "maxScore": 0.0 },
"pii": { "entities": [], "count": 0 },
"sentiment": { "dominant": "NEUTRAL", "scores": {} }
},
"file_hash": "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"metadata": { "user_id": "usr_456" },
"completed_at": "2026-03-23T12:01:30Z"
}
{
"event": "scan.complete",
"scan_id": "scn_01HX9B2C3D4E5F6G7H8I9J0K1L",
"verdict": "nsfw",
"virus": {
"clean": true,
"signature": null
},
"nsfw": {
"clean": false,
"categories": ["explicit_nudity"],
"confidence": 0.98
},
"text": {
"clean": true,
"toxic": { "labels": [], "maxScore": 0.0 },
"pii": { "entities": [], "count": 0 },
"sentiment": { "dominant": "NEUTRAL", "scores": {} }
},
"file_hash": "sha256:f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2",
"metadata": { "source": "profile-picture" },
"completed_at": "2026-03-23T12:02:45Z"
}
{
"event": "scan.complete",
"scan_id": "scn_01HX9C3D4E5F6G7H8I9J0K1L2M",
"verdict": "toxic",
"virus": {
"clean": true,
"signature": null
},
"nsfw": {
"clean": true,
"categories": [],
"confidence": 0.01
},
"text": {
"clean": false,
"toxic": { "labels": ["HATE_SPEECH"], "maxScore": 0.92 },
"pii": { "entities": [], "count": 0 },
"sentiment": { "dominant": "NEGATIVE", "scores": {} }
},
"file_hash": "sha256:d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3",
"metadata": { "source": "comment-upload" },
"completed_at": "2026-03-23T12:03:15Z"
}
EventDescription
scan.completeScan has finished processing. Contains the full verdict and all results.
scan.verdict_updatedA previously completed scan’s verdict was revised by post-processing analysis. The payload contains the updated verdict and results. Handle this by replacing the original result.

In rare cases, post-processing analysis may revise a scan’s verdict after the initial scan.complete event. When this happens, a scan.verdict_updated webhook is sent with the corrected result:

{
"event": "scan.verdict_updated",
"scan_id": "scn_01HX7Z9K3M2N4P5Q6R7S8T9U0V",
"verdict": "infected",
"previous_verdict": "clean",
"virus": {
"clean": false,
"signature": "Win.Trojan.Agent-789012"
},
"nsfw": {
"clean": true,
"categories": [],
"confidence": 0.01
},
"text": {
"clean": true,
"toxic": { "labels": [], "maxScore": 0.0 },
"pii": { "entities": [], "count": 0 },
"sentiment": { "dominant": "NEUTRAL", "scores": {} }
},
"file_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"metadata": { "user_id": "usr_123" },
"completed_at": "2026-03-23T12:05:00Z"
}

Your webhook handler should treat scan.verdict_updated the same as scan.complete, replacing any previously stored result for the given scan_id.

FileSafety expects your webhook endpoint to respond with a 2xx status code (e.g., 200 OK).

  • If your endpoint returns a non-2xx status or the connection fails, the delivery is retried up to 5 times (6 total delivery attempts, including the initial attempt).
  • After all retries are exhausted, the webhook is marked as failed. The scan result remains available via GET /v1/scan/{id} indefinitely.

Return a 200 response immediately, before doing any heavy processing. Process the webhook payload asynchronously:

app.post("/webhooks/filesafety", (req, res) => {
res.status(200).send("ok");
// Process asynchronously
processWebhook(req.body).catch(console.error);
});

Your webhook endpoint may receive the same payload more than once (due to retries or network issues). Use the scan_id field to deduplicate:

async function processWebhook(payload) {
const alreadyProcessed = await db.exists(`webhook:${payload.scan_id}`);
if (alreadyProcessed) return;
await db.set(`webhook:${payload.scan_id}`, true);
// Handle the scan result...
}

Include identifiers in the metadata field when submitting scans so you can route webhook payloads back to the correct user, upload, or workflow:

{
"metadata": {
"user_id": "usr_123",
"upload_id": "upl_456",
"source": "profile-picture"
}
}

Verify that the scan_id in the webhook payload matches a scan you actually submitted. This prevents processing payloads from unknown sources.