Skip to content
Malwagon
Sign up

API reference

base url
https://malwagon.com
authentication
Bearer token in the Authorization header. Nothing else is accepted.
rest
Five routes under /api/v1. JSON in, JSON out, no trailing slashes.
mcp
POST /mcp, JSON-RPC 2.0, revision 2026-07-28. Enterprise plans only.
media type
application/json on every request body and every response body.
caching
Every response carries Cache-Control: no-store. No session is read or created.
Bytes go to one route, and only the REST API has itread this first
A file is uploaded to POST /api/v1/scans/file as multipart/form-data with a single part named file. The sample is typed by its content rather than by its name, so the module and the sandbox are chosen for you: a Windows binary detonates on Windows, an ELF or a Python package on the Linux sandbox, a driver reaches the kernel analyzer. The MCP server has no equivalent and is not getting one: its caller is a language model, and bytes it chose to send are not a decision anybody reviewed. The other four modules still name their target as text, and GET /api/v1/hashes/<sha256> still reads what this platform already holds about a digest without starting a machine.

Command line

The quickest path from a file on your disk to a verdict, and the one that needs no code. The client is Python, has no dependencies, and runs on Linux, macOS and Windows.

Install and scantwo commands
pip install malwagon
export MALWAGON_API_KEY="mwg_a1b2c3d4_kZq7Xn2R9tLpWv0sYcB4hJdF6gMaE3uNbT1xQfV5rDo"

malwagon suspicious.exe
What it printsstderr is progress, stdout is the result
uploading suspicious.exe (412.0 KB) to malwagon.com
scan 48213 queued
waiting for the sandbox, usually 90 to 300 seconds
  queued       0s elapsed
  running      12s elapsed
  analyzing    2m 18s elapsed

  MALICIOUS  score 88/100
  sha256 354fd5f5e4afc2280a19c8541fd4abe38bf8fb73efbeb3c2b0a4f2b1d9e0c7a1
  report https://malwagon.com/s/48213
The sandbox is chosen from the file
A Windows binary detonates on Windows, an ELF or a pip package on the Linux sandbox, a driver reaches the kernel analyzer. --os overrides it where a plan allows more than one image, but there is nothing to set for the ordinary case.
It exits with the verdict
0 clean, 1 malicious, 3 suspicious, 2 for an error, so a build step can gate on it. --json prints one object on stdout while the progress stays on stderr, which is what makes malwagon sample.bin --json | jq work while you still see the wait.
Your key is never an argument
It is read from MALWAGON_API_KEY, from a file with --api-key-file, from stdin with --api-key-stdin, or from one malwagon login. There is deliberately no --api-key flag: a credential on the command line is visible in the process list and is written verbatim into shell history.
It does not trust this server
TLS is verified and there is no flag to turn that off, redirects are reported rather than followed, and a stored key is bound to the host it was stored for. Everything the server sends is stripped of terminal control sequences before it is printed, and the verdict is read from a fixed vocabulary rather than echoed.
Free and paid
A Community key works and runs a network isolated scan, so no egress, no reputation lookups and no AI narrative. The client says which layers did not run rather than leaving an empty section to be read as "the sample did nothing".

malwagon on PyPI, and malwagon --help lists every option.

Quickstart

Mint a token in the console, POST a target, then poll the returned scan id until it reports itself terminal. Every sample on this page assumes the two environment variables below, and switches with the language control in the left rail.

Environmentassumed by every sample
export MWG_BASE="https://malwagon.com"
export MWG_TOKEN="mwg_a1b2c3d4_kZq7Xn2R9tLpWv0sYcB4hJdF6gMaE3uNbT1xQfV5rDo"
import os
import requests

BASE = os.environ.get("MWG_BASE", "https://malwagon.com")
HEADERS = {"Authorization": "Bearer " + os.environ["MWG_TOKEN"]}
$Base    = $env:MWG_BASE
if (-not $Base) { $Base = "https://malwagon.com" }
$Headers = @{ Authorization = "Bearer $env:MWG_TOKEN" }
The token is read from the environment in every sample rather than written into the code. It is a credential with your account's credits behind it, and a token pasted into a repository is a token to revoke.

1. Mint a token

Tokens are created at Settings, API and MCP tokens in the console. You give the token a name, decide whether it may submit, and optionally set an expiry between 1 and 365 days. The raw value is displayed once at creation and stored only as a keyed hash, so it cannot be recovered afterwards, only revoked and replaced.

Token shapeshown once
mwg_a1b2c3d4_kZq7Xn2R9tLpWv0sYcB4hJdF6gMaE3uNbT1xQfV5rDo
---  --------  -------------------------------------------
 |       |                        |
 |       |                        +-- secret, 43 characters, 256 bits from a CSPRNG
 |       +--------------------------- token id, 8 hex characters, what audit logs name
 +----------------------------------- fixed prefix
Three parts joined by underscores, and only the first two separators are separators: the secret is base64url, so it may itself contain - and _. Split on the first two underscores, not on every one. The mwg_ prefix is fixed so a leaked token is recognisable to a secret scanner and to a human reading a paste.

2. Submit a target

Submitting queues one scan and answers 202 Accepted with the scan's identity. It spends your account's own credits and is subject to your plan's concurrency limit.

Submit a URL for detonationscope: submit
curl -sS -X POST "$MWG_BASE/api/v1/scans" \
  -H "Authorization: Bearer $MWG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"module": "url", "target": "https://invoice-portal.example/pay", "private": true}'
reply = requests.post(
    BASE + "/api/v1/scans",
    headers=HEADERS,
    json={
        "module": "url",
        "target": "https://invoice-portal.example/pay",
        "private": True,
    },
    timeout=30,
)
reply.raise_for_status()
scan_id = reply.json()["scan"]["scan_id"]
$Body = @{
    module  = "url"
    target  = "https://invoice-portal.example/pay"
    private = $true
} | ConvertTo-Json

$Reply = Invoke-RestMethod -Method Post -Uri "$Base/api/v1/scans" `
    -Headers $Headers -ContentType "application/json" -Body $Body
$ScanId = $Reply.scan.scan_id

3. Poll, then read

Poll the scan id until terminal is true. When report_available turns true, the report endpoint has a full result for you. Polling is a read, and reads have a far larger budget than submissions.

Poll until terminal, then fetch the reportscope: read
while :; do
  scan=$(curl -sS "$MWG_BASE/api/v1/scans/48213" -H "Authorization: Bearer $MWG_TOKEN")
  echo "$scan" | grep -q '"terminal": true' && break
  sleep 10
done

curl -sS "$MWG_BASE/api/v1/scans/48213/report" -H "Authorization: Bearer $MWG_TOKEN"
import time

while True:
    scan = requests.get(
        BASE + "/api/v1/scans/%d" % scan_id, headers=HEADERS, timeout=30
    ).json()["scan"]
    if scan["terminal"]:
        break
    time.sleep(10)

if scan["report_available"]:
    report = requests.get(
        BASE + "/api/v1/scans/%d/report" % scan_id, headers=HEADERS, timeout=60
    ).json()["report"]
    print(report["verdict"]["label"], report["verdict"]["score"])
do {
    Start-Sleep -Seconds 10
    $Scan = (Invoke-RestMethod -Method Get -Headers $Headers `
        -Uri "$Base/api/v1/scans/$ScanId").scan
} until ($Scan.terminal)

if ($Scan.report_available) {
    $Report = (Invoke-RestMethod -Method Get -Headers $Headers `
        -Uri "$Base/api/v1/scans/$ScanId/report").report
    "{0} {1}" -f $Report.verdict.label, $Report.verdict.score
}
A scan reaches a terminal status on its own; there is no callback you can register yourself. See Boundaries. Note that Invoke-RestMethod throws on any non-2xx status, so a PowerShell client that wants to read an error body has to wrap the call in try and read $_.ErrorDetails.Message.

Authentication

Every call authenticates with a single bearer token in the Authorization header, and with nothing else: there is no cookie fallback, no query parameter and no form field.

The only accepted credentialheader only
Authorization: Bearer mwg_a1b2c3d4_kZq7Xn2R9tLpWv0sYcB4hJdF6gMaE3uNbT1xQfV5rDo
A credential in a URL lands in access logs and referrer headers, and a credential in a cookie is one a browser attaches without being asked. Neither is accepted here. No session is read and none is created, and every response carries Cache-Control: no-store.

Scopes

There are exactly two scopes, and a token's grants are fixed at creation.

The two scopes
ScopeGrantedCovers
read Every token Scan status, report and hash lookup, and the four read tools on the MCP server.
submit Opt-in at creation The two submit routes and submit_scan. A valid token without it is refused 403 forbidden on REST. On MCP the tool is hidden from tools/list and a call reports it as an unknown tool.

What a plan has to include

The two surfaces are gated on two separate entitlements, so the same token can be accepted by one and refused by the other. The plan is read live on every call rather than stamped onto the token, so a plan change takes effect on the next request with no propagation delay.

Which plans reach which surface
PlanREST APIMCP server
CommunityNoNo
ProYesNo
BusinessYesNo
Team / EnterpriseYesYes

The MCP server is included on every paid plan. Pro, Business and Team include both the REST API and not include MCP. A token minted on either of them is a perfectly valid token that authenticates on /api/ and is refused on /mcp, with the plan's own wording, before any tool runs. See Pricing.

A Pro token on the MCP endpoint401 Unauthorized
{
  "jsonrpc": "2.0",
  "error": {
    "code": -31001,
    "message": "Unauthorized: MCP access for AI clients is not included in your plan. Upgrade to use it."
  },
  "id": 1
}
The refusal arrives at the transport layer, so it looks the same whichever tool was being called. The equivalent on the REST API for a plan without API access is 401 unauthorized in the JSON error envelope.

Revocation and expiry

Revocation
Immediate, from the tokens page. Revoking never cancels a scan that has already started.
Expiry
Optional, from 1 to 365 days. Leaving it empty mints a token with no expiry date.
Rotation
Mint the replacement, move your clients onto it, then revoke the old one. A token is shown once and cannot be recovered, so there is no way to re-read one you have lost.

Errors

Every JSON refusal on the REST API uses one envelope: an error object with a machine-readable code and a human-readable message. Branch on code, never on the message text.

The error envelopeall JSON errors
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Cache-Control: no-store
Retry-After: 2841

{
  "error": {
    "code": "rate_limited",
    "message": "api_submit is limited to 10 per 3600 seconds; try again in 2841 seconds"
  }
}
Every code the REST API emits
StatuscodeWhen you see it
400bad_requestThe body was not JSON, was not an object, or an argument was not usable. The message says which.
401unauthorizedNo token, a token that does not verify, an expired or revoked token, or a plan that does not include this surface.
403forbiddenA valid token without the scope the route costs, which in practice means submitting with a read-only token.
404not_foundNo such scan, or one this token may not read. The two answer identically, so the response does not distinguish them.
413payload_too_largeThe request body is over the 128 KB cap. On the REST API the token is checked first, so an unauthenticated oversized body answers 401 rather than this; the MCP endpoint checks the size before it authenticates and answers 413 either way.
429rate_limitedThe budget for this scope is spent. A Retry-After header says how many seconds until the window rolls over.
500internal_errorSomething failed here. The message is deliberately opaque and carries no detail about this system.

Two exits that are not JSON

Two failure modes leave the JSON contract entirely, so a client that assumes a parseable body on every response will break on them.

Wrong method
A GET to a POST-only route, or the reverse, answers 405 with an Allow header and a zero-length body. There is no error envelope to parse.
Unrouted path
A path that matches no route, such as a misspelled endpoint or a trailing slash, falls through to the site's own 404 page and returns HTML rather than JSON. Check the status code and the content type before parsing.

Rate limits and caps

Rate limits are fixed windows counted per user rather than per token, so minting a second token does not buy a second budget.

Per-user rate limits
SurfaceScopeLimitWindowCounter unreachable
REST APIread12060 secondsAllowed
REST APIsubmit103600 secondsRefused
MCP serverread12060 secondsAllowed
MCP serversubmit103600 secondsRefused

Reads have the larger budget because polling a running scan is the normal shape of an integration. Submits are hourly because each one spends credits and occupies a detonation lane, and the submit budget fails closed: if the counter itself cannot be reached the submit is refused rather than waved through. On the MCP surface the budget is charged on tools/call only, and a spent budget comes back as a tool error inside a 200 rather than as a 429.

These figures are the ones configured today and an operator can retune them, so read them from a response rather than pinning them in a client: on a 429, back off for the number of seconds the Retry-After header names.

Request caps
CapValueApplies to
Request body128 KBBoth the REST API and the MCP endpoint. Over it is 413.
Bulk submissions25One /api/v1/scans/bulk request.
target length2048Characters, on every submission.
Credits and concurrencyby planSubmissions are also bounded by your plan's monthly credits and concurrent scan limit.

A submission refused for credits or concurrency comes back as 400 bad_request carrying the plan's own wording, because that is a refusal you can act on. Plan figures are on the pricing page.

REST API v1

Seven routes, mounted under /api/, with no trailing slashes and no version other than v1. Each one is listed below with its signature, its parameters and a worked request.

POST /api/v1/scans scope: submit202 Accepted

Queues a single analysis of a target named as text and answers 202 with the new scan's identity.

Body parameters
FieldTypeRequiredDescription
module string required One of hash, url, command, package. See the module table below.
target string required Up to 2048 characters. Its meaning follows the module, and it is validated against that module before anything is queued.
private boolean optional Keeps the scan off the public corpus. Needs a plan that includes private scans; on a plan without them the field is accepted and then ignored, so read the scan's visibility back rather than assuming it took.
What target means for each module
moduletargetRunsEgress
hash A 64 character hex SHA-256 digest. An intelligence lookup. No virtual machine is started and nothing is detonated. None
url An http:// or https:// URL. A real browser visits the address in an analysis VM and the page's behaviour is recorded. Required
command A command line, up to 2048 characters. The command runs in a Windows VM under observation. None
package A specifier like requests or requests[security]==2.31.0. The package is installed in a Linux VM and the install-time behaviour is recorded. Required

url and package have no network isolated mode: the browser exists to fetch the page and the guest exists to install from the index, so both detonate with internet access from inside the sandbox. On a plan that runs every scan network isolated they are refused before a credit is spent, with a 400 bad_request naming the module.

RequestPOST /api/v1/scans
curl -sS -X POST "$MWG_BASE/api/v1/scans" \
  -H "Authorization: Bearer $MWG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "module": "command",
        "target": "powershell -nop -w hidden -enc SQBFAFgAIAAoAE4AZQB3AC0A",
        "private": true
      }'
reply = requests.post(
    BASE + "/api/v1/scans",
    headers=HEADERS,
    json={
        "module": "command",
        "target": "powershell -nop -w hidden -enc SQBFAFgAIAAoAE4AZQB3AC0A",
        "private": True,
    },
    timeout=30,
)
if reply.status_code != 202:
    raise SystemExit(reply.json()["error"]["message"])
scan = reply.json()["scan"]
$Body = @{
    module  = "command"
    target  = "powershell -nop -w hidden -enc SQBFAFgAIAAoAE4AZQB3AC0A"
    private = $true
} | ConvertTo-Json

try {
    $Scan = (Invoke-RestMethod -Method Post -Uri "$Base/api/v1/scans" `
        -Headers $Headers -ContentType "application/json" -Body $Body).scan
} catch {
    ($_.ErrorDetails.Message | ConvertFrom-Json).error.message
}
Response202 Accepted
{
  "scan": {
    "scan_id": 48213,
    "module": "command",
    "status": "queued",
    "verdict": null,
    "score": null,
    "tags": {"items": [], "returned": 0, "total": 0, "truncated": false},
    "submitted_at": "2026-08-30T09:14:02.118433+00:00",
    "finished_at": null,
    "sha256": "0d4a9f6be3c25f18a7c0e9b41d2f8a6c53e7b0149ad6c8f2317be0d54c9a1f7e",
    "size": 58,
    "mime": "text/plain",
    "terminal": false,
    "report_available": false
  }
}

POST /api/v1/scans/file scope: submit202 Accepted

Uploads a sample and queues a scan of it. This is the only route on either surface that takes bytes, and it answers with the same scan object the text route does.

Multipart parts
PartTypeRequiredDescription
file file required The sample, up to 128 MB. Exactly one; a request carrying several is refused rather than having the first of them scanned. An empty file is refused.
private boolean optional Keeps the report private. Coerced to public on a plan without private scans, the same way the console form is.
internet boolean optional Detonates with egress. Refused with 403 forbidden on a plan that runs every scan network isolated, rather than being quietly ignored.
os string optional Pins a sandbox image. Refused with 403 naming the images your plan does include. Leave it out and the platform picks Windows or Linux from the sample itself.
timeout integer optional Seconds the sample runs, within your plan's cap.
dynamic boolean optional Defaults true. Send false for static analysis with no detonation.
ai boolean optional Defaults true, and withheld by plan rather than by this field: a plan without the AI layer gets the flag set and the layer skipped.

There is no module parameter, and that is deliberate. The sample is typed by its content, so a Windows binary detonates on Windows, an ELF or a pip package on the Linux sandbox, and a driver reaches the kernel analyzer whatever the file was named. A module field would be a way to route a sample past the analyzer that would have understood it.

This route spends two budgets rather than one: the submit budget every write route spends, and the upload budget, because it writes a sample to quarantine storage before a credit is charged. Either can answer 429 with Retry-After.

RequestPOST /api/v1/scans/file
curl -sS -X POST "$MWG_BASE/api/v1/scans/file" \
  -H "Authorization: Bearer $MWG_TOKEN" \
  -F "[email protected]" \
  -F "private=true"
with open("suspicious.exe", "rb") as handle:
    scan = requests.post(
        BASE + "/api/v1/scans/file",
        headers=HEADERS,
        files={"file": ("suspicious.exe", handle)},
        data={"private": "true"},
        timeout=300,
    ).json()["scan"]

print(scan["scan_id"])
$Form = @{ file = Get-Item "suspicious.exe"; private = "true" }
$Scan = (Invoke-RestMethod -Method Post -Uri "$Base/api/v1/scans/file" `
  -Headers $Headers -Form $Form).scan
$Scan.scan_id
Response202 Accepted
{
  "scan": {
    "scan_id": 48219,
    "module": "file",
    "status": "queued",
    "verdict": null,
    "score": null,
    "tags": {"items": [], "returned": 0, "total": 0, "truncated": false},
    "submitted_at": "2026-09-05T21:18:07.482119+00:00",
    "finished_at": null,
    "sha256": "354fd5f5e4afc2280a19c8541fd4abe38bf8fb73efbeb3c2b0a4f2b1d9e0c7a1",
    "size": 421888,
    "mime": "application/x-dosexec",
    "terminal": false,
    "report_available": false
  }
}

Poll GET /api/v1/scans/<id> with the returned id, exactly as for a text submission.

POST /api/v1/scans/bulk scope: submit200 OK

Queues up to 25 submissions in one request and answers 200 with one result row per item, in the order you sent them.

Body parameters
FieldTypeRequiredDescription
submissions array required Non-empty, at most 25 entries. Each entry is an object with the same module, target and private fields the single submit route takes.
Per-item outcomes
A bad or refused item never sinks the batch: the envelope is still 200 and the failure is carried in that item's own row as {"ok": false, "error": ...}.
Budget
The submit budget is charged once per item, so a bulk request cannot be used to get past the hourly submit ceiling. Once the budget is spent, every remaining item comes back as rate_limited rather than being queued, which tells you exactly where the batch stopped.
RequestPOST /api/v1/scans/bulk
curl -sS -X POST "$MWG_BASE/api/v1/scans/bulk" \
  -H "Authorization: Bearer $MWG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "submissions": [
          {"module": "hash",    "target": "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4"},
          {"module": "package", "target": "requests==2.31.0"},
          {"module": "url",     "target": "not-a-url"}
        ]
      }'
batch = [
    {"module": "hash", "target": "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4"},
    {"module": "package", "target": "requests==2.31.0"},
    {"module": "url", "target": "not-a-url"},
]
results = requests.post(
    BASE + "/api/v1/scans/bulk",
    headers=HEADERS,
    json={"submissions": batch},
    timeout=60,
).json()["results"]

for sent, row in zip(batch, results):
    if row["ok"]:
        print(sent["target"], "queued as", row["scan"]["scan_id"])
    else:
        print(sent["target"], "refused:", row["error"]["message"])
$Batch = @(
    @{ module = "hash";    target = "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4" }
    @{ module = "package"; target = "requests==2.31.0" }
    @{ module = "url";     target = "not-a-url" }
)
$Body = @{ submissions = $Batch } | ConvertTo-Json -Depth 5

$Results = (Invoke-RestMethod -Method Post -Uri "$Base/api/v1/scans/bulk" `
    -Headers $Headers -ContentType "application/json" -Body $Body).results

foreach ($Row in $Results) {
    if ($Row.ok) { "queued as {0}" -f $Row.scan.scan_id }
    else         { "refused: {0}"  -f $Row.error.message }
}
PowerShell serialises nested structures only two levels deep by default, so ConvertTo-Json needs an explicit -Depth here or the submission objects arrive as the string System.Collections.Hashtable.
Response200 OK
{
  "results": [
    {"ok": true,  "scan": {"scan_id": 48214, "module": "hash", "status": "queued"}},
    {"ok": true,  "scan": {"scan_id": 48215, "module": "package", "status": "queued"}},
    {"ok": false, "error": {"code": "bad_request",
                            "message": "a url submission needs an http:// or https:// URL"}}
  ]
}
Check every row. A 200 here means the batch was processed, not that every item was accepted. Each scan object carries the same fields the single submit route returns; the rows above are shortened to keep the example readable.

GET /api/v1/scans/<id> scope: read200 OK

Returns one scan's current status, and is the endpoint to poll after a submit.

Path and response fields
FieldTypeRequiredDescription
id integer path The scan's numeric id, as returned by a submit.
terminal boolean response True once the scan will not change again. Stop polling when you see it.
report_available boolean response True only for completed. A failed or timed out scan is terminal without a report.
Every value the status field can hold
statusTerminalMeaning
queuednoAccepted and waiting for a detonation lane.
provisioningnoA virtual machine is being prepared for the run.
runningnoThe target is executing under observation.
analyzingnoThe run is over and the collected telemetry is being processed.
completedyesFinished. This is the only status with a report.
failedyesThe analysis did not finish. No report.
cancelledyesStopped before it finished. No report.
timed_outyesThe run hit the plan's maximum duration. No report.
RequestGET /api/v1/scans/48213
curl -sS "$MWG_BASE/api/v1/scans/48213" \
  -H "Authorization: Bearer $MWG_TOKEN"
scan = requests.get(
    BASE + "/api/v1/scans/48213", headers=HEADERS, timeout=30
).json()["scan"]

print(scan["status"], scan["verdict"], scan["score"])
$Scan = (Invoke-RestMethod -Method Get -Headers $Headers `
    -Uri "$Base/api/v1/scans/48213").scan

"{0} {1} {2}" -f $Scan.status, $Scan.verdict, $Scan.score
Response200 OK
{
  "scan": {
    "scan_id": 48213,
    "module": "command",
    "status": "completed",
    "verdict": "malicious",
    "score": 82,
    "tags": {"items": ["activity:evasion", "technique:t1497"], "returned": 2,
             "total": 2, "truncated": false},
    "submitted_at": "2026-08-30T09:14:02.118433+00:00",
    "finished_at": "2026-08-30T09:16:41.902117+00:00",
    "sha256": "0d4a9f6be3c25f18a7c0e9b41d2f8a6c53e7b0149ad6c8f2317be0d54c9a1f7e",
    "size": 58,
    "mime": "text/plain",
    "terminal": true,
    "report_available": true
  }
}
A scan that does not exist and a scan you may not read answer identically with 404 not_found. The response does not distinguish the two. Note that verdict is a bare string here and an object on the report route.

GET /api/v1/scans/<id>/report scope: read200 OK

Returns the derived analysis report for a scan, wrapped in a report key. It does not require the scan to have finished: an unfinished one answers 200 with whatever has been derived so far, which is why the status route publishes report_available. The report is built key by key from an allowlist rather than serialised from the database, so it contains only the fields named in The report object. Sample bytes, decompiled source, artifact identifiers and download links are not fields this endpoint can emit.

RequestGET /api/v1/scans/48213/report
curl -sS "$MWG_BASE/api/v1/scans/48213/report" \
  -H "Authorization: Bearer $MWG_TOKEN"
report = requests.get(
    BASE + "/api/v1/scans/48213/report", headers=HEADERS, timeout=60
).json()["report"]

for ioc in report["iocs"]["items"]:
    print(ioc["type"], ioc["value"])
if report["iocs"]["truncated"]:
    print("cut at", report["iocs"]["returned"], "of", report["iocs"]["total"])
$Report = (Invoke-RestMethod -Method Get -Headers $Headers `
    -Uri "$Base/api/v1/scans/48213/report").report

$Report.iocs.items | ForEach-Object { "{0} {1}" -f $_.type, $_.value }
if ($Report.iocs.truncated) {
    "cut at {0} of {1}" -f $Report.iocs.returned, $Report.iocs.total
}
Response200 OK
{
  "report": {
    "module": "file",
    "verdict": {"label": "malicious", "score": 75},
    "static": {
      "yara_matches": {"items": ["IsPE32", "HasRichSignature"], "returned": 2,
                       "total": 2, "truncated": false},
      "signed": false,
      "static_risk_score": 10,
      "import_count": 61
    },
    "dynamic": {
      "platform": "windows",
      "behavior_summary": "Decoded a base64 command line, then fetched and ran a second stage.",
      "network": {"connections": {"items": [], "returned": 0,
                                  "total": 0, "truncated": false}}
    },
    "intel": {"vt_malicious": 0, "lol_match": false},
    "iocs": {
      "items": [{"type": "imphash", "value": "e38062877caac65585afa2d2c3200df4"}],
      "returned": 1, "total": 1, "truncated": false
    },
    "run_integrity": {"failed": false, "reached_sample": null,
                      "cause": "", "note": ""},
    "scan_id": 48213,
    "status": "completed",
    "tags": {"items": ["activity:evasion"], "returned": 1, "total": 1, "truncated": false},
    "submitted_at": "2026-08-30T09:14:02.118433+00:00",
    "finished_at": "2026-08-30T09:16:41.902117+00:00",
    "sha256": "32acc1bc543116cbe2cff10cb867772df2f254ff2634c870aef0b46c4b696fdb",
    "size": 11776,
    "mime": "application/vnd.microsoft.portable-executable"
  }
}
Indicator values are returned defanged, the same way the console exports them. An absent section and an absent field both mean the layer produced nothing for this scan: an isolated hash scan has an empty dynamic object rather than a missing one.

GET /api/v1/indicators scope: read200 OK

Returns the scans in which an indicator was observed: an address, a domain, a URL, a mutex, a registry key or a digest. A network sensor sees these and never sees a sample, so this is the route an enrichment integration asks when it has no file in hand.

Query and response fields
FieldTypeRequiredDescription
value string query The indicator to look for. Defanged forms are accepted and normalised, so a value pasted out of another report finds the same rows a raw one would.
type string query Narrows the search to one kind: domain, email, filepath, imphash, ip, ja3, ja4, md5, mutex, registry, sha1, sha256, url or user_agent. Anything else is 400 bad_request.
matches object response A bounded list of scan summaries, each carrying the ioc_type and the defanged ioc that matched.
Exact match only
The value is matched whole, never as a substring. A substring search across a corpus you cannot otherwise read would let you confirm somebody else's indicator one character at a time.
Visibility
The same rule as everywhere else: your own scans plus genuinely public ones. An indicator seen only by a stranger's private scan answers with an empty list, exactly as an indicator nobody has ever seen does.
RequestGET /api/v1/indicators
curl -s -G "https://malwagon.com/api/v1/indicators" \
  --data-urlencode "value=evil.example" \
  --data-urlencode "type=domain" \
  -H "Authorization: Bearer $MALWAGON_API_KEY"

GET /api/v1/hashes/<sha256> scope: read200 OK

Returns the scans of one SHA-256 that this token may read. It is the only route whose body is not wrapped in a scan or report key.

Path and response fields
FieldTypeRequiredDescription
sha256 string path A 64 character hex SHA-256 digest. Anything else is 400 bad_request.
scans object response A bounded list of scan summaries, capped at 25, newest first.
Visibility
Your own scans of those bytes plus any genuinely public scan of them. An unknown digest and a digest you cannot see both answer with an empty list, without distinguishing the two.
size and mime
A row for a scan whose own submission carried no bytes, which means a hash module scan, reports 0 and "" here rather than the stored sample's real size and type. In those two fields a digest this platform holds and one it has never seen therefore look the same.
RequestGET /api/v1/hashes/<sha256>
digest="9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4"

curl -sS "$MWG_BASE/api/v1/hashes/$digest" \
  -H "Authorization: Bearer $MWG_TOKEN"
digest = "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4"

found = requests.get(
    BASE + "/api/v1/hashes/" + digest, headers=HEADERS, timeout=30
).json()

print(found["scans"]["total"], "prior analyses")
for row in found["scans"]["items"]:
    print(row["scan_id"], row["verdict"], row["score"])
$Digest = "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4"

$Found = Invoke-RestMethod -Method Get -Headers $Headers `
    -Uri "$Base/api/v1/hashes/$Digest"

"{0} prior analyses" -f $Found.scans.total
$Found.scans.items | ForEach-Object { "{0} {1} {2}" -f $_.scan_id, $_.verdict, $_.score }
Response200 OK
{
  "sha256": "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4",
  "scans": {
    "items": [
      {"scan_id": 47190, "module": "file", "status": "completed",
       "verdict": "malicious", "score": 88,
       "tags": {"items": ["activity:av-detection"], "returned": 1,
                "total": 1, "truncated": false},
       "submitted_at": "2026-08-19T11:02:44.501992+00:00",
       "finished_at": "2026-08-19T11:06:02.774310+00:00",
       "sha256": "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4",
       "size": 184320,
       "mime": "application/vnd.microsoft.portable-executable"}
    ],
    "returned": 1,
    "total": 1,
    "truncated": false
  }
}
Note the shape: sha256 and scans sit at the top level of the body, with no wrapper key.

The report object

A report is a derived summary of one completed scan: four analysis sections, an indicator list, a run-integrity block and the scan's own identity fields.

Top-level keys, in the order they are emitted
KeyTypeDescription
modulestringWhich analysis module produced this scan.
verdictobjectlabel and score. An object here, not the bare string the status route returns, so parse it as an object.
staticobjectWhat was learned without running anything: capability mapping, packer and signing facts, YARA matches, imports and section names, macro and phishing signals, and format-specific detail for ELF, Go, Python source and Python packages.
dynamicobjectWhat the sample did while it ran: platform and detonation mode, a behaviour summary, the API and syscall sequence, file and registry operations, dropped files, spawned processes, persistence surfaces, network connections and DNS queries, ATT&CK technique mappings and evasion signals.
intelobjectWhat third-party reputation sources say about the derived indicators, including labels and living-off-the-land matches. Only hashes and derived indicators are ever sent out to produce this.
iocsbounded listPairs of {"type", "value"}, defanged, capped at 100.
run_integrityobjectWhether the detonation itself reached the sample: failed, reached_sample, cause, note. Read it before concluding a sample did nothing, because a run that never started looks the same as a sample that sat still.
scan_idintegerThe scan's own id, the same one you polled.
statusstringThe scan's status, the same value the status route reports. This route does not refuse an unfinished scan: it returns what has been derived so far, so a scan that is still running answers 200 with mostly empty sections. Poll until report_available before treating a report as final.
tagsbounded listAnalyst-facing labels the platform assigned, capped at 32.
submitted_atstringISO 8601 with an offset, or null.
finished_atstringISO 8601 with an offset, or null.
sha256stringThe digest of the analysed bytes.
sizeintegerByte length, or 0 for a scan that carried no bytes of its own.
mimestringDetected media type, or "" for a scan that carried no bytes of its own.
Every list is bounded and says solist shape
"dropped_files": {
  "items":     [ ... ],
  "returned":  100,
  "total":     417,
  "truncated": true
}
Truncation is never silent, so a client can tell forty operations from four thousand rather than guessing. Read truncated before treating a list as complete, and total rather than the length of items when you are counting.

For what the fields mean in analyst terms, see the glossary and the documentation. Worked examples of finished reports are in the public analyses index.

MCP server

MCP needs a paid planplan gate
Every paid plan carries mcp_access. The free tier does not: a token minted there authenticates normally on /api/ and is refused 401 here, at the transport layer, before any tool runs. Everything in this section is written for a plan that includes it.

The server speaks JSON-RPC 2.0 over plain HTTP at POST /mcp, with no SSE stream, no session and no initialize handshake. It implements revision 2026-07-28, which is the only version it advertises. This page describes the server. No claim is made here about which MCP clients interoperate with it, and no client has been tested against this endpoint; treat compatibility as something to verify yourself.

Wire rules

One endpoint, one method
POST /mcp. GET and DELETE answer 405 with an Allow header, because this revision has no SSE stream to open and no session to tear down. A request that carries an Origin header is answered 403 unless that origin is one the operator has configured, so this endpoint is for clients that are not browsers.
No handshake, no session
Authentication is per-request bearer, exactly as on the REST API. Every request carries its own protocol version and client capabilities instead of negotiating them once.
One message per body
Batches are not part of this revision. A body must be a single JSON-RPC request or notification, and a notification, meaning a message with no id, is accepted with 202 and no reply.

Two things every request must carry, and both are checked before authentication, so getting them wrong produces a protocol error rather than a permission error.

Required headers, and the body value each one mirrors
HeaderRequired onMust equal
MCP-Protocol-VersionEvery requestparams._meta["io.modelcontextprotocol/protocolVersion"]
Mcp-MethodEvery requestmethod
Mcp-Nametools/callparams.name
Required params._meta keys
KeyTypeValue
io.modelcontextprotocol/protocolVersionstringMust be 2026-07-28. Any other value is refused, and the refusal names the versions the server supports.
io.modelcontextprotocol/clientCapabilitiesobjectRequired, and must be a JSON object. An empty object is fine.

A mirrored header that disagrees with the body is rejected rather than resolved, so an intermediary routing on the header and this server executing the body can never act on different requests. These are the exact refusals, and each is a JSON-RPC error with an HTTP status to match.

JSON-RPC error codes on the MCP endpoint
codeHTTPMeaning
-32700400The body is not valid JSON.
-32600400Not a single JSON-RPC message object, or jsonrpc is not "2.0", or the id is not a string or integer. Also the code used for a rejected Origin (403) and an oversized body (413).
-32601404Method not found. Only three methods exist.
-32602400Invalid params: a missing or malformed _meta key, a missing name, non-object arguments, or an unknown tool.
-32603500Internal error. Deliberately opaque.
-32020400Header mismatch: a required mirrored header is missing or disagrees with the body.
-32022400Unsupported protocol version. The data object names what is supported.
-31001401Unauthorized: no token, a token that does not verify, or a plan without MCP access.
-31002403Forbidden: a valid token without the scope a non-tools/call method costs.
Three refusals you will meet while wiring a clientverbatim
{"jsonrpc": "2.0", "error": {"code": -32020,
  "message": "Header mismatch: MCP-Protocol-Version is required"}, "id": 1}

{"jsonrpc": "2.0", "error": {"code": -32602,
  "message": "Invalid params: _meta must carry io.modelcontextprotocol/clientCapabilities"}, "id": 1}

{"jsonrpc": "2.0", "error": {"code": -32022, "message": "Unsupported protocol version",
  "data": {"supported": ["2026-07-28"], "requested": "2025-11-25"}}, "id": 1}
All three are produced before the token is looked at, so you can shake a client's envelope out without a working credential: an unauthenticated request with a correct envelope answers -31001, and anything else means the envelope is still wrong.

Methods

Three methods are served, and anything else answers 404 with a method-not-found error.

server/discover
Replaces initialize. Returns the supported protocol versions, the server capabilities (tools only, no resources and no prompts) and usage instructions.
tools/list
The tools this token may see, with their input and output schemas. A token without the submit grant is not shown submit_scan at all.
tools/call
Runs one tool. The result carries both a serialised text block and a structuredContent object, plus an isError flag.
A reusable tool callerused by every tool below
mcp_call() {
  # $1 tool name, $2 arguments as a JSON object
  curl -sS -X POST "$MWG_BASE/mcp" \
    -H "Authorization: Bearer $MWG_TOKEN" \
    -H "Content-Type: application/json" \
    -H "MCP-Protocol-Version: 2026-07-28" \
    -H "Mcp-Method: tools/call" \
    -H "Mcp-Name: $1" \
    -d "{
          \"jsonrpc\": \"2.0\",
          \"id\": 1,
          \"method\": \"tools/call\",
          \"params\": {
            \"name\": \"$1\",
            \"arguments\": $2,
            \"_meta\": {
              \"io.modelcontextprotocol/protocolVersion\": \"2026-07-28\",
              \"io.modelcontextprotocol/clientCapabilities\": {}
            }
          }
        }"
}
VERSION = "2026-07-28"

def mcp_call(name, arguments, rid=1):
    body = {
        "jsonrpc": "2.0",
        "id": rid,
        "method": "tools/call",
        "params": {
            "name": name,
            "arguments": arguments,
            "_meta": {
                "io.modelcontextprotocol/protocolVersion": VERSION,
                "io.modelcontextprotocol/clientCapabilities": {},
            },
        },
    }
    reply = requests.post(
        BASE + "/mcp",
        json=body,
        timeout=60,
        headers={
            "Authorization": HEADERS["Authorization"],
            "MCP-Protocol-Version": VERSION,
            "Mcp-Method": "tools/call",
            "Mcp-Name": name,
        },
    ).json()
    if "error" in reply:
        raise RuntimeError(reply["error"]["message"])
    result = reply["result"]
    if result["isError"]:
        raise RuntimeError(result["structuredContent"]["error"])
    return result["structuredContent"]
$Version = "2026-07-28"

function Invoke-McpTool {
    param([string]$Name, [hashtable]$Arguments)

    $Body = @{
        jsonrpc = "2.0"
        id      = 1
        method  = "tools/call"
        params  = @{
            name      = $Name
            arguments = $Arguments
            _meta     = @{
                "io.modelcontextprotocol/protocolVersion"    = $Version
                "io.modelcontextprotocol/clientCapabilities" = @{}
            }
        }
    } | ConvertTo-Json -Depth 8

    $McpHeaders = @{
        Authorization          = "Bearer $env:MWG_TOKEN"
        "MCP-Protocol-Version" = $Version
        "Mcp-Method"           = "tools/call"
        "Mcp-Name"             = $Name
    }
    $Reply = Invoke-RestMethod -Method Post -Uri "$Base/mcp" `
        -Headers $McpHeaders -ContentType "application/json" -Body $Body

    if ($Reply.result.isError) { throw $Reply.result.structuredContent.error }
    $Reply.result.structuredContent
}
-Depth 8 is not decoration. PowerShell serialises two levels by default, which turns clientCapabilities into the string "System.Collections.Hashtable" and earns a -32602 saying _meta must carry it. A tool that fails answers 200 with isError true and the reason in structuredContent.error, so both callers above raise on that rather than on the HTTP status.

Tools

Five tools, four of them read-only. Each result carries a content array with the serialised JSON and a structuredContent object with the same data parsed, so a client can read whichever it prefers. A scan that does not exist and one this token may not read answer not found identically.

read lookup_hash read-only

Finds analyses of a known SHA-256 digest: the caller's own scans of those bytes plus any publicly shared scan of them. It sends nothing anywhere, it searches scans that already exist here. An unknown digest and one this token cannot see both answer with an empty list.

Arguments
NameTypeRequiredDescription
sha256stringrequiredExactly 64 hex characters.
Calltools/call
mcp_call lookup_hash \
  '{"sha256": "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4"}'
found = mcp_call("lookup_hash", {
    "sha256": "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4",
})
print(found["scans"]["total"], "prior analyses")
$Found = Invoke-McpTool -Name "lookup_hash" -Arguments @{
    sha256 = "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4"
}
"{0} prior analyses" -f $Found.scans.total
Result200 OK
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [{"type": "text", "text": "{\"sha256\": \"9c1f...\", ...}"}],
    "structuredContent": {
      "sha256": "9c1f0b6d2ae74318bd50c7a9e6f34182bb0d5e7c94a2f61038de7b5c0a91f2d4",
      "scans": {"items": [], "returned": 0, "total": 0, "truncated": false}
    },
    "isError": false,
    "resultType": "complete",
    "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "sandbox",
                                                     "version": "1.0.0"}}
  }
}

Inside static, dynamic and intel

Every field a section can carry, with the cap that bounds it. A bounded list is the envelope shown above: items, returned, total, truncated. A field absent from a particular scan is absent from the response rather than null, so read defensively.

static 24 fields

FieldTypeCapObject fields
capabilities_attack bounded list of string 40 items -
capabilities_mbc bounded list of string 40 items -
clamav object - infected signature
deobfuscation_techniques bounded list of string 40 items -
dotnet_type_count string, number, boolean or null - -
elf object - needed_libs section_names static_linked stripped suspicious_symbols
go object - dependencies interesting_functions
import_count string, number, boolean or null - -
imported_dlls bounded list of string 30 items -
indicators bounded list of string 40 items -
macro_keywords bounded list of string 30 items -
macros_present string, number, boolean or null - -
packer object - packed packer signals
phishing_score string, number, boolean or null - -
phishing_signals bounded list of string 20 items -
python_package object - dependencies name typosquat version
python_source object - dangerous_calls imports network_indicators obfuscation_techniques
section_names bounded list of string 20 items -
signed string, number, boolean or null - -
static_risk_engine string 64 characters -
static_risk_score string, number, boolean or null - -
static_risk_signals bounded list of string 10 items -
was_obfuscated string, number, boolean or null - -
yara_matches bounded list of string 30 items -

dynamic 16 fields

FieldTypeCapObject fields
api_sequence bounded list of string 200 items -
behavior_summary string 2000 characters -
detonation_mode string, number, boolean or null - -
dns object - available count queries
dropped_files bounded list of object 100 items executable md5 path pid process sha256 size timestamp
evasion_signals bounded list of string 40 items -
file_operations bounded list of object 100 items op path pid process sha256 size timestamp
mitre bounded list of string 40 items -
mitre_techniques bounded list of object 40 items id tactic technique
network object - connections protocols unique_remotes
persistence bounded list of object 50 items command_line name path surface
pip_install object - from_file install_time_execution side_effects success target
platform string, number, boolean or null - -
registry_operations bounded list of object 100 items key op pid process timestamp value
spawned_processes bounded list of object 100 items command_line name path pid
syscall_summary object, string keys to counts 100 keys -

intel 4 fields

FieldTypeCapObject fields
lol_match string, number, boolean or null - -
malwarebazaar_signature string 256 characters -
vt_labels bounded list of string 40 items -
vt_malicious string, number, boolean or null - -

read get_report read-only

The derived analysis report for one scan, the same object documented above: verdict, capabilities, behaviour summary, observed operations and defanged indicators. Derived data only. It never contains the sample's bytes, its decompiled source, a download link or an artifact reference.

Arguments
NameTypeRequiredDescription
scan_idintegerrequiredThe scan's numeric id. A decimal string is accepted too.
Calltools/call
mcp_call get_report '{"scan_id": 48213}'
report = mcp_call("get_report", {"scan_id": 48213})
print(report["verdict"]["label"], report["verdict"]["score"])
for ioc in report["iocs"]["items"]:
    print(ioc["type"], ioc["value"])
$Report = Invoke-McpTool -Name "get_report" -Arguments @{ scan_id = 48213 }
"{0} {1}" -f $Report.verdict.label, $Report.verdict.score
$Report.iocs.items | ForEach-Object { "{0} {1}" -f $_.type, $_.value }
The structuredContent here is the report object with the same keys the REST report route returns, without the report wrapper.

read search_indicator read-onlyno REST equivalent

Finds scans where an indicator was observed: an IP, a domain, a URL, a mutex, a registry key, a hash or a JA3/JA4 fingerprint. The indicator is matched exactly rather than as a substring, and defanged input such as evil[.]com is refanged first. Only scans this token may read are searched. This is the one capability the MCP surface has and the REST surface does not.

Arguments
NameTypeRequiredDescription
indicatorstringrequiredThe exact value, up to 512 characters. Defanged forms are accepted.
typestringoptionalNarrows the search. One of ip, domain, url, md5, sha1, sha256, imphash, mutex, registry, filepath, email, ja3, ja4, user_agent.
Calltools/call
mcp_call search_indicator \
  '{"indicator": "cdn-update[.]example", "type": "domain"}'
hits = mcp_call("search_indicator", {
    "indicator": "cdn-update[.]example",
    "type": "domain",
})
for row in hits["matches"]["items"]:
    print(row["scan_id"], row["verdict"], row["ioc"])
$Hits = Invoke-McpTool -Name "search_indicator" -Arguments @{
    indicator = "cdn-update[.]example"
    type      = "domain"
}
$Hits.matches.items | ForEach-Object { "{0} {1} {2}" -f $_.scan_id, $_.verdict, $_.ioc }
structuredContentresult
{
  "indicator": "cdn-update[.]example",
  "matches": {
    "items": [
      {"scan_id": 48213, "module": "url", "status": "completed",
       "verdict": "malicious", "score": 82,
       "ioc_type": "domain", "ioc": "cdn-update[.]example"}
    ],
    "returned": 1, "total": 1, "truncated": false
  }
}
Each match is a scan summary with the matching ioc_type and ioc appended. Results are capped at 50 and total reports the true count.

read poll_scan read-only

The current status of one scan, for polling after submit_scan. Cheap enough to call in a loop. terminal means the scan will not change again; report_available means get_report will return a full report.

Arguments
NameTypeRequiredDescription
scan_idintegerrequiredThe scan's numeric id.
Calltools/call
mcp_call poll_scan '{"scan_id": 48213}'
import time

while True:
    scan = mcp_call("poll_scan", {"scan_id": 48213})
    if scan["terminal"]:
        break
    time.sleep(10)
do {
    Start-Sleep -Seconds 10
    $Scan = Invoke-McpTool -Name "poll_scan" -Arguments @{ scan_id = 48213 }
} until ($Scan.terminal)
The read budget is 120 calls a minute, so a ten second poll interval leaves plenty of room for other reads on the same account.

submit submit_scan spends credits

Queues a new analysis of a target that can be named as text: a SHA-256 to look up, a URL to visit, a command to run, or a package to install. Uploading a file or a document is not possible over MCP. This spends the account's own credits and is subject to its plan limits. A token without the submit grant does not see this tool in tools/list, and a call to it is reported as an unknown tool rather than as a permission failure.

Arguments
NameTypeRequiredDescription
modulestringrequiredOne of hash, url, command, package. url and package detonate with internet access and are refused on a plan without it.
targetstringrequiredThe digest, URL, command line or package specifier, matching the chosen module. Up to 2048 characters.
privatebooleanoptionalKeeps the scan off the public corpus. Free plans cannot make a scan private and this is ignored for them.
Calltools/call
mcp_call submit_scan \
  '{"module": "url", "target": "https://invoice-portal.example/pay", "private": true}'
queued = mcp_call("submit_scan", {
    "module": "url",
    "target": "https://invoice-portal.example/pay",
    "private": True,
})
scan_id = queued["scan_id"]
$Queued = Invoke-McpTool -Name "submit_scan" -Arguments @{
    module  = "url"
    target  = "https://invoice-portal.example/pay"
    private = $true
}
$ScanId = $Queued.scan_id
structuredContentresult
{
  "scan_id": 48216,
  "module": "url",
  "status": "queued",
  "verdict": null,
  "score": null,
  "tags": {"items": [], "returned": 0, "total": 0, "truncated": false},
  "submitted_at": "2026-08-30T09:22:10.004821+00:00",
  "finished_at": null,
  "sha256": "b19c0f0e8d1a4f2c7e6b5a34d2019f8c6e4b7a1d3f5c9e2b8a4d6f0c1e3b5a79",
  "size": 41,
  "mime": "text/plain",
  "terminal": false,
  "report_available": false
}
A refusal for credits, concurrency or a missing egress entitlement comes back as a tool error inside a 200, carrying the plan's own wording, so a model can read it and stop rather than retry.

Boundaries

These are limits of both surfaces as they stand, not gaps waiting to be filled.

One file per upload, and no upload over MCP
The upload route takes one part named file and refuses a request carrying several, because a scan is one sample. The MCP surface takes no bytes at all. The four text modules are unchanged: a SHA-256, a URL, a command line or a package specifier.
No sample, ever
The API never returns a sample, a download URL, an artifact identifier, a memory dump, a packet capture or decompiled sample source. Reports are derived data, assembled field by field from an allowlist, so a field nobody named cannot appear.
You read your own scans and genuinely public ones
Nothing else is reachable, including scans belonging to other accounts. A scan that does not exist and a scan you may not read give the same answer, so the two cases are not told apart by the response.
No self-service webhooks
There is no endpoint for registering a callback, and no way to create one from the console. Where a webhook is in use, an operator has configured it. Poll the scan id instead; that is what the status route is sized for.
No client compatibility claim
The MCP server implements revision 2026-07-28 as described above. No MCP client has been tested against it, so nothing here should be read as a statement that any particular client will work.
A token is shown once
The raw value exists only in the response that created it and is stored only as a keyed hash. If you lose it, revoke it and mint another.
This page describes the running code
Endpoints, parameters, error codes and rate limits can change. Where this reference and the API disagree, the API is the authority, and the difference is worth reporting through the contact page.

Sign in

Sign in

The analyst console and your scan history. Private scans and the API come with a paid plan.

or
Continue with Google

New team? Create a free account

Sign up

Create your account

Free tier: 20 scans a month, three sandbox images, reports public. No card required.

12 characters minimum

or
Continue with Google

By creating an account you accept the terms and privacy policy.

Already provisioned? Sign in