API Documentation
Scan any file — binary, archive, document, image — from your own code, servers and CI pipelines, and get a malware verdict from ClamAV, YARA and our image-payload check.
Authentication
Every request carries an API key: your personal key from the API dashboard (Premium), or one of an organization's keys (vpo_…) from its settings.
Pass the key using one of these methods:
- Authorization header (recommended):
Authorization: Bearer vp_<your-api-key> - Custom header:
X-API-Key: vp_<your-api-key>
How scanning works
Your files never pass through our web servers. Scanning is two requests: you ask the API for a short-lived, signed permit, then upload the file directly to an isolated scanner with it. The scanner answers with the verdict and records it against the scan's id, so you can also read it back later — or have it pushed to you.
POST /api/v1/scanwith the file's name and size →scanId,permit,uploadUrl. This reserves one scan of your daily quota.POSTthe file touploadUrlasmultipart/form-data(fieldfile) with the headerX-Scan-Permit: <permit>within 120 seconds → the verdict.- Lost the connection, or scanning asynchronously?
GET /api/v1/scan/{scanId}returns the recorded verdict at any time, and a webhook can deliver it to your server as soon as it lands.
Endpoints
/api/v1/scan— Start a scanRequest body (JSON)
| Field | Type | Description |
|---|---|---|
| fileName | string | The file's name, up to 512 characters. Archives (.zip, .rar, .7z, .tar, .gz…) are unpacked and scanned file by file. |
| fileSize | number | Size in bytes. The upload is refused if it is larger than this or than your plan allows. |
| fileMimeType | string, optional | The file's MIME type. |
Response
| Field | Type | Description |
|---|---|---|
| scanId | string | Keep it: it identifies the scan from now on. |
| permit | string | Send as X-Scan-Permit with the upload. Valid for 120 seconds, for one upload. |
| uploadUrl | string | Where to POST the file. |
| maxSizeBytes | number | The largest upload the permit allows. |
| isArchive | boolean | Whether the file will be unpacked and its contents scanned. |
| statusUrl | string | GET this to read the verdict later. |
{
"scanId": "3b241101-e2bb-4255-8caf-4136c566a962",
"permit": "eyJzaWQiOiIzYjI0MTEwMS1lMmJi...",
"uploadUrl": "https://scan.viruspurge.com/scan",
"maxSizeBytes": 268435456,
"isArchive": false,
"statusUrl": "https://viruspurge.com/api/v1/scan/3b241101-e2bb-4255-8caf-4136c566a962"
}{uploadUrl}— Upload the file to the scanner Multipart form with one field, file, and the header X-Scan-Permit. No API key is needed: the permit is the authorisation. The response holds isInfected, viruses, yaraMatches, imageFindings and sha256.
/api/v1/scan/{scanId}— Get a scan's verdict The scan as recorded. While status is SCANNING, the response carries Retry-After with a suggested polling interval in seconds. A scan that belongs to anyone else returns 404, exactly like one that does not exist. Reads have their own budget of 300 requests a minute per key, so polling never eats into the scans you can start.
Response fields
| Field | Type | Description |
|---|---|---|
| scanId | string | The scan's identifier. |
| status | SCANNING | CLEAN | INFECTED | ERROR | SCANNING until the verdict is recorded. ERROR when the scan could not finish — for example the file was never uploaded. |
| isInfected | boolean | null | true if any engine found a threat; null while SCANNING. |
| viruses | string[] | ClamAV signature names (with the in-archive path for archives). |
| yaraMatches | string[] | YARA rule names. |
| imageFindings | string[] | Payloads hidden inside images. |
| sha256 | string | null | SHA-256 of the uploaded bytes. |
| fileName / fileSize / fileMimeType | string / number / string | As declared when the scan was started. |
| isArchive | boolean | Whether the file was unpacked and its contents scanned. |
| engines | string[] | The checks that ran: clamav, image, yara. |
| scanDuration | number | null | Milliseconds from permit to verdict. |
| createdAt / completedAt | ISO 8601 | When the scan started, and when it reached its final status (null while SCANNING). |
{
"scanId": "3b241101-e2bb-4255-8caf-4136c566a962",
"status": "INFECTED",
"isInfected": true,
"fileName": "setup.exe",
"fileSize": 204800,
"fileMimeType": "application/x-msdownload",
"sha256": "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f",
"isArchive": false,
"viruses": ["Win.Test.EICAR_HDB-1"],
"yaraMatches": [],
"imageFindings": [],
"engines": ["clamav", "image", "yara"],
"scanDuration": 412,
"createdAt": "2026-11-11T12:34:56.000Z",
"completedAt": "2026-11-11T12:34:57.000Z"
}/api/v1/webhooks— List webhooks/api/v1/webhooks— Create a webhook/api/v1/webhooks/{webhookId}— Delete a webhook/api/v1/webhooks/{webhookId}/test— Send a signed test eventSee Webhooks below.
/api/v1/openapi.json— OpenAPI 3.1 specification The full machine-readable contract, including the webhook payloads. Load it into Postman, Insomnia or Swagger UI, or generate a typed client with openapi-generator. No key needed.
Webhooks
Instead of polling, register an HTTPS endpoint and we POST a signed JSON event to it. Webhooks belong to the key's owner — your account, or the organization for an organization key — and are managed with the same API key, so a pipeline can register its own receiver. Up to 10 per owner.
| Event | Sent when |
|---|---|
| scan.completed | Any of your scans reaches CLEAN, INFECTED or ERROR — from the API or the website. data is the same object GET /api/v1/scan/{scanId} returns. |
| scan.infected | A scan comes back INFECTED (sent in addition to scan.completed). |
| quota.threshold | Your trailing-24-hour usage crosses 80% or 100% of your daily limit. Each threshold fires at most once a day. A team's alerts go to the team owner and the member whose scan crossed it. |
# Create — the response holds the signing secret, shown only once
curl -s https://viruspurge.com/api/v1/webhooks \
-H "Authorization: Bearer $VIRUSPURGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://ci.example.com/hooks/viruspurge","events":["scan.infected","quota.threshold"]}'
# Send a signed test event
curl -s -X POST https://viruspurge.com/api/v1/webhooks/wh_…/test \
-H "Authorization: Bearer $VIRUSPURGE_API_KEY"
# Or with the CLI
npx viruspurge webhooks create https://ci.example.com/hooks/viruspurge --events scan.infected
npx viruspurge webhooks listPOST /your/webhook HTTP/1.1
Content-Type: application/json
VirusPurge-Event: scan.infected
VirusPurge-Delivery: dlv_6f1c0e2a9b8d4c3e
VirusPurge-Signature: t=1762864497,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_9b1d4c3e2f6a4b8c9d0e1f2a3b4c5d6e",
"type": "scan.infected",
"createdAt": "2026-11-11T12:34:57.120Z",
"data": { "scanId": "3b241101-…", "status": "INFECTED", … }
}{
"id": "evt_…",
"type": "quota.threshold",
"createdAt": "2026-11-11T18:02:11.000Z",
"data": {
"scope": "personal",
"threshold": 80,
"used": 240,
"limit": 300,
"remaining": 60,
"window": "24h"
}
}Verifying signatures
VirusPurge-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "{t}.{raw body}" keyed with the webhook's secret. Compare in constant time and reject timestamps older than five minutes, so a captured delivery cannot be replayed.
import crypto from "node:crypto";
// `rawBody` must be the exact bytes received — verify before parsing JSON.
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replayed
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False # replayed
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Delivery
- Answer with any 2xx within 10 seconds. Anything else is retried after 10 s, 1 min, 5 min and 30 min.
- Delivery is at-least-once: de-duplicate on the event's
id. The scan endpoint stays the source of truth if an event is missed. - Targets must be public HTTPS URLs. Redirects are not followed.
- The webhook list shows each one's last delivery and consecutive failures;
POST /api/v1/webhooks/{id}/testsends a signedpingon demand.
CLI & GitHub Action
enterprise_required. Scan release artifacts and build outputs before they ship. The viruspurge CLI (Node 20+, no dependencies) scans files, directories and globs, recovers verdicts when an upload connection drops, and exits non-zero when malware is found — so it fits any CI system.
| Exit code | Meaning |
|---|---|
| 0 | Every file is clean (empty files are skipped). |
| 1 | Malware was found. |
| 2 | A file could not be scanned, or the API refused the request (pass --no-fail-on-error to tolerate scan failures). |
On GitHub Actions and GitLab CI the CLI attaches the pipeline context automatically — the ci object of POST /api/v1/scan (repository, ref, commit, workflow, run id and URL, actor), which GET /api/v1/scan/{scanId} and the webhooks return too.
On GitHub, the Virus Purge Scan action wraps the CLI: it annotates each infected file, writes a summary table to the job page, and exposes a JSON report as an output.
- uses: decentralisoft/virus-scanner-cicd-action@v1
id: scan
with:
api-key: ${{ secrets.VIRUSPURGE_API_KEY }} # required
files: | # required — paths, dirs or globs
dist/**
!dist/**/*.map
fail-on-infected: true # default
fail-on-error: true # default
if-no-files-found: error # error | warn | ignore
concurrency: 4
# outputs: steps.scan.outputs.result (clean | infected | error | none),
# scanned, infected, errors, report (path to the JSON report)Code Examples
API_KEY="vp_<your-api-key>"
# 1. Ask for an upload permit (the file itself never goes to this API)
RESP=$(curl -s https://viruspurge.com/api/v1/scan \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"fileName":"file.pdf","fileSize":'$(wc -c < file.pdf)'}')
# 2. Upload the file straight to the scanner — the response is the verdict
curl -s "$(echo "$RESP" | jq -r .uploadUrl)" \
-H "X-Scan-Permit: $(echo "$RESP" | jq -r .permit)" \
-F "[email protected]"
# 3. Any time later — or if the upload connection dropped — read it back
curl -s "https://viruspurge.com/api/v1/scan/$(echo "$RESP" | jq -r .scanId)" \
-H "Authorization: Bearer $API_KEY"import os, time, httpx
API = "https://viruspurge.com/api/v1"
HEADERS = {"Authorization": "Bearer vp_<your-api-key>"}
path = "file.pdf"
# 1. Permit
permit = httpx.post(f"{API}/scan", headers=HEADERS, json={
"fileName": os.path.basename(path),
"fileSize": os.path.getsize(path),
}).raise_for_status().json()
# 2. Upload straight to the scanner
try:
with open(path, "rb") as f:
verdict = httpx.post(permit["uploadUrl"], files={"file": f},
headers={"X-Scan-Permit": permit["permit"]},
timeout=300).raise_for_status().json()
except httpx.TransportError:
# 3. Connection lost: the verdict is still recorded under the scan id
while (verdict := httpx.get(f"{API}/scan/{permit['scanId']}",
headers=HEADERS).json())["status"] == "SCANNING":
time.sleep(2)
print(verdict["isInfected"], verdict["viruses"])import { openAsBlob, statSync } from "node:fs";
const API = "https://viruspurge.com/api/v1";
const headers = { Authorization: "Bearer vp_<your-api-key>" };
const path = "file.pdf";
// 1. Permit
const permit = await fetch(`${API}/scan`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ fileName: path, fileSize: statSync(path).size }),
}).then((r) => r.json());
// 2. Upload straight to the scanner (Node 20+: no dependencies)
const form = new FormData();
form.append("file", await openAsBlob(path), path);
const verdict = await fetch(permit.uploadUrl, {
method: "POST",
headers: { "X-Scan-Permit": permit.permit },
body: form,
}).then((r) => r.json());
// 3. Or read it back later: GET `${API}/scan/${permit.scanId}`
console.log(verdict.isInfected, verdict.viruses);# Node 20+. Scans files, directories and globs; exits 1 if malware is found.
export VIRUSPURGE_API_KEY="vp_<your-api-key>"
npx viruspurge scan dist/ "build/**/*.{zip,exe,dmg}"
# Machine-readable report, e.g. to archive with a release
npx viruspurge scan dist/ --json --report scan-report.json
# Look a scan up again by id
npx viruspurge status <scanId># .github/workflows/scan-release.yml
# Add your API key as the repository secret VIRUSPURGE_API_KEY.
name: Scan release artifacts
on:
release:
types: [published]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- run: gh release download "${{ github.event.release.tag_name }}" -D dist -R "$GITHUB_REPOSITORY"
env:
GH_TOKEN: ${{ github.token }}
- uses: decentralisoft/virus-scanner-cicd-action@v1
with:
api-key: ${{ secrets.VIRUSPURGE_API_KEY }}
files: dist/**Errors
Errors are JSON: { "error": "…" }.
| Status | Meaning |
|---|---|
| 200 | Success. |
| 201 | Created (webhooks). |
| 400 | Bad request — the JSON body is missing or invalid. |
| 401 | Unauthorized — the API key is missing or invalid. |
| 403 | Forbidden — no Premium plan (or, for an organization key, no active subscription). |
| 404 | Not found — or not yours. |
| 409 | Conflict — the webhook limit is reached. |
| 413 | Payload too large — the file exceeds the 256 MB limit. |
| 429 | Too many requests — with Retry-After: the per-minute rate limit, retry after that many seconds. Without it: the daily quota is spent. |
| 500 | Server error. Safe to retry. |
{
"error": "Invalid API key."
}Limits & Quotas
| Limit | Value |
|---|---|
| Max file size | 256 MB |
| Scan quota | 300 / day (Premium), counted over a trailing 24 hours; higher volumes on Enterprise |
| Starting scans | 60 / minute / API key |
| Reads & webhook management | 300 / minute / API key |
| Permit lifetime | 120 seconds |
| Webhooks | 10 per account or organization |
| Archive scanning | ZIP, RAR, 7z, TAR, GZ — up to 5 levels deep |
| Image payload check | JPG, PNG, GIF, WebP, BMP, SVG — every scan |
| Request timeout | 300 seconds |
Successful responses carry X-RateLimit-Remaining so you can see your per-minute budget before you hit it.
Archive Scanning
When you submit an archive file (ZIP, RAR, 7z, TAR, or GZ), the scanner unpacks it into a temporary working directory and scans every file inside individually. Nested archives are recursively expanded up to a safe depth limit (5 levels deep) to prevent zip-bomb denial-of-service.
- If any file inside the archive is infected, the whole archive is
INFECTED, andviruseslists every detected threat with its in-archive path. - Password-protected archives cannot be inspected.
- Archives larger than 256 MB uncompressed are rejected even if the compressed file is under the size limit.
Best Practices
- Persist the scanId. Store it with your record of the file. If the upload connection drops,
GET /api/v1/scan/{scanId}still has the verdict — don't start a new scan. - Prefer webhooks to tight polling. If you do poll, honour
Retry-After. - Stream uploads. For large files, stream rather than buffer the entire file in memory before sending.
- Handle 429s with backoff. Wait for
Retry-Afterwhen it is present; without it, the daily quota is spent and retrying won't help. - Watch your quota. Subscribe to
quota.thresholdso a release pipeline never discovers the limit mid-run. - Treat the API key as a secret. Never embed it in client-side JavaScript or mobile app binaries. Proxy through your own backend.
Changelog
CI/CD integration is now an Enterprise feature: scans from a pipeline carry a ci context (repository, commit, ref, workflow run) and are organized per GitHub owner, repository and run on the CI/CD page.
Added GET /api/v1/scan/{scanId} to read a verdict at any time, webhooks (scan.completed, scan.infected, quota.threshold), the OpenAPI 3.1 spec at /api/v1/openapi.json, the viruspurge CLI and the Virus Purge Scan GitHub Action.
Updated plan limits: maximum file size is now 256 MB and Premium includes 300 scans per day. Higher volumes are available on the Enterprise plan.
Increased maximum file size from 256 MB to 512 MB. Added per-minute rate limit headers.
Added archive deep-scanning for ZIP, RAR, 7z, TAR, and GZ. Added the isArchive field to scan responses.
Initial public API. Single scan endpoint, JSON responses, Bearer authentication.
Ready to integrate?
API access is available on the Premium plan. Upgrade your account to get your API key.