Developer API

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.

REST / JSON OpenAPI 3.1 Premium required Up to 256 MB

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>
Keep your API key secret. Do not commit it to source control or expose it to browsers — in CI, store it as a secret.

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.

  1. POST /api/v1/scan with the file's name and size → scanId, permit, uploadUrl. This reserves one scan of your daily quota.
  2. POST the file to uploadUrl as multipart/form-data (field file) with the header X-Scan-Permit: <permit> within 120 seconds → the verdict.
  3. 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

POST/api/v1/scan— Start a scan

Request body (JSON)

FieldTypeDescription
fileNamestringThe file's name, up to 512 characters. Archives (.zip, .rar, .7z, .tar, .gz…) are unpacked and scanned file by file.
fileSizenumberSize in bytes. The upload is refused if it is larger than this or than your plan allows.
fileMimeTypestring, optionalThe file's MIME type.

Response

FieldTypeDescription
scanIdstringKeep it: it identifies the scan from now on.
permitstringSend as X-Scan-Permit with the upload. Valid for 120 seconds, for one upload.
uploadUrlstringWhere to POST the file.
maxSizeBytesnumberThe largest upload the permit allows.
isArchivebooleanWhether the file will be unpacked and its contents scanned.
statusUrlstringGET this to read the verdict later.
200 OK
{
  "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"
}
POST{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.

GET/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

FieldTypeDescription
scanIdstringThe scan's identifier.
statusSCANNING | CLEAN | INFECTED | ERRORSCANNING until the verdict is recorded. ERROR when the scan could not finish — for example the file was never uploaded.
isInfectedboolean | nulltrue if any engine found a threat; null while SCANNING.
virusesstring[]ClamAV signature names (with the in-archive path for archives).
yaraMatchesstring[]YARA rule names.
imageFindingsstring[]Payloads hidden inside images.
sha256string | nullSHA-256 of the uploaded bytes.
fileName / fileSize / fileMimeTypestring / number / stringAs declared when the scan was started.
isArchivebooleanWhether the file was unpacked and its contents scanned.
enginesstring[]The checks that ran: clamav, image, yara.
scanDurationnumber | nullMilliseconds from permit to verdict.
createdAt / completedAtISO 8601When the scan started, and when it reached its final status (null while SCANNING).
200 OK
{
  "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"
}
GET/api/v1/webhooks— List webhooks
POST/api/v1/webhooks— Create a webhook
DELETE/api/v1/webhooks/{webhookId}— Delete a webhook
POST/api/v1/webhooks/{webhookId}/test— Send a signed test event

See Webhooks below.

GET/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.

EventSent when
scan.completedAny 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.infectedA scan comes back INFECTED (sent in addition to scan.completed).
quota.thresholdYour 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.
manage
# 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 list
delivery
POST /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", … }
}
quota.threshold
{
  "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.

Node.js
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));
}
Python
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}/test sends a signed ping on demand.

CLI & GitHub Action

Enterprise plans only. In a pipeline, every scan carries the repository, commit, branch and workflow run it came from, and the results are organized per repository and run on your CI/CD page (and each organization's CI/CD tab). On any other plan the API refuses pipeline scans with 403 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 codeMeaning
0Every file is clean (empty files are skipped).
1Malware was found.
2A 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.

workflow step
- 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"

Errors

Errors are JSON: { "error": "…" }.

StatusMeaning
200Success.
201Created (webhooks).
400Bad request — the JSON body is missing or invalid.
401Unauthorized — the API key is missing or invalid.
403Forbidden — no Premium plan (or, for an organization key, no active subscription).
404Not found — or not yours.
409Conflict — the webhook limit is reached.
413Payload too large — the file exceeds the 256 MB limit.
429Too many requests — with Retry-After: the per-minute rate limit, retry after that many seconds. Without it: the daily quota is spent.
500Server error. Safe to retry.
401 Unauthorized
{
  "error": "Invalid API key."
}

Limits & Quotas

LimitValue
Max file size256 MB
Scan quota300 / day (Premium), counted over a trailing 24 hours; higher volumes on Enterprise
Starting scans60 / minute / API key
Reads & webhook management300 / minute / API key
Permit lifetime120 seconds
Webhooks10 per account or organization
Archive scanningZIP, RAR, 7z, TAR, GZ — up to 5 levels deep
Image payload checkJPG, PNG, GIF, WebP, BMP, SVG — every scan
Request timeout300 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, and viruses lists 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-After when it is present; without it, the daily quota is spent and retrying won't help.
  • Watch your quota. Subscribe to quota.threshold so 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

v1.52026-11-12

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.

v1.42026-11-11

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.

v1.32026-07-13

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.

v1.22026-04-12

Increased maximum file size from 256 MB to 512 MB. Added per-minute rate limit headers.

v1.12026-01-30

Added archive deep-scanning for ZIP, RAR, 7z, TAR, and GZ. Added the isArchive field to scan responses.

v1.02025-11-01

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.