API reference
- base url
https://malwagon.com- authentication
- Bearer token in the
Authorizationheader. Nothing else is accepted. - rest
- Five routes under
/api/v1. JSON in, JSON out, no trailing slashes. - mcp
POST /mcp, JSON-RPC 2.0, revision2026-07-28. Enterprise plans only.- media type
application/jsonon every request body and every response body.- caching
- Every response carries
Cache-Control: no-store. No session is read or created.
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.
pip install malwagon export MALWAGON_API_KEY="mwg_a1b2c3d4_kZq7Xn2R9tLpWv0sYcB4hJdF6gMaE3uNbT1xQfV5rDo" malwagon suspicious.exe
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.
--osoverrides it where a plan allows more than one image, but there is nothing to set for the ordinary case. - It exits with the verdict
0clean,1malicious,3suspicious,2for an error, so a build step can gate on it.--jsonprints one object on stdout while the progress stays on stderr, which is what makesmalwagon sample.bin --json | jqwork 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 onemalwagon login. There is deliberately no--api-keyflag: 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.
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" }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.
mwg_a1b2c3d4_kZq7Xn2R9tLpWv0sYcB4hJdF6gMaE3uNbT1xQfV5rDo --- -------- ------------------------------------------- | | | | | +-- secret, 43 characters, 256 bits from a CSPRNG | +--------------------------- token id, 8 hex characters, what audit logs name +----------------------------------- fixed prefix
- 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.
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_id3. 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.
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
}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.
Authorization: Bearer mwg_a1b2c3d4_kZq7Xn2R9tLpWv0sYcB4hJdF6gMaE3uNbT1xQfV5rDo
Cache-Control: no-store.Scopes
There are exactly two scopes, and a token's grants are fixed at creation.
| Scope | Granted | Covers |
|---|---|---|
| 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.
| Plan | REST API | MCP server |
|---|---|---|
| Community | No | No |
| Pro | Yes | No |
| Business | Yes | No |
| Team / Enterprise | Yes | Yes |
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.
{
"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
}
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.
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"
}
}
| Status | code | When you see it |
|---|---|---|
| 400 | bad_request | The body was not JSON, was not an object, or an argument was not usable. The message says which. |
| 401 | unauthorized | No token, a token that does not verify, an expired or revoked token, or a plan that does not include this surface. |
| 403 | forbidden | A valid token without the scope the route costs, which in practice means submitting with a read-only token. |
| 404 | not_found | No such scan, or one this token may not read. The two answer identically, so the response does not distinguish them. |
| 413 | payload_too_large | The 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. |
| 429 | rate_limited | The budget for this scope is spent. A Retry-After header says how many seconds until the window rolls over. |
| 500 | internal_error | Something 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
405with anAllowheader 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
404page 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.
| Surface | Scope | Limit | Window | Counter unreachable |
|---|---|---|---|---|
| REST API | read | 120 | 60 seconds | Allowed |
| REST API | submit | 10 | 3600 seconds | Refused |
| MCP server | read | 120 | 60 seconds | Allowed |
| MCP server | submit | 10 | 3600 seconds | Refused |
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.
| Cap | Value | Applies to |
|---|---|---|
| Request body | 128 KB | Both the REST API and the MCP endpoint. Over it is 413. |
| Bulk submissions | 25 | One /api/v1/scans/bulk request. |
| target length | 2048 | Characters, on every submission. |
| Credits and concurrency | by plan | Submissions 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
Queues a single analysis of a target named as text and answers 202 with the new scan's
identity.
| Field | Type | Required | Description |
|---|---|---|---|
| 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. |
| module | target | Runs | Egress |
|---|---|---|---|
| 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.
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
}{
"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
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.
| Part | Type | Required | Description |
|---|---|---|---|
| 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.
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{
"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
Queues up to 25 submissions in one request and answers 200 with one result row per item, in
the order you sent them.
| Field | Type | Required | Description |
|---|---|---|---|
| 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
200and 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_limitedrather than being queued, which tells you exactly where the batch stopped.
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 }
}ConvertTo-Json needs an explicit -Depth here or the submission objects arrive as
the string System.Collections.Hashtable.{
"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"}}
]
}
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>
Returns one scan's current status, and is the endpoint to poll after a submit.
| Field | Type | Required | Description |
|---|---|---|---|
| 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. |
| status | Terminal | Meaning |
|---|---|---|
| queued | no | Accepted and waiting for a detonation lane. |
| provisioning | no | A virtual machine is being prepared for the run. |
| running | no | The target is executing under observation. |
| analyzing | no | The run is over and the collected telemetry is being processed. |
| completed | yes | Finished. This is the only status with a report. |
| failed | yes | The analysis did not finish. No report. |
| cancelled | yes | Stopped before it finished. No report. |
| timed_out | yes | The run hit the plan's maximum duration. No report. |
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{
"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
}
}
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
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.
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
}{
"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"
}
}
hash scan has an empty dynamic object rather than a missing one.
GET
/api/v1/indicators
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.
| Field | Type | Required | Description |
|---|---|---|---|
| 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.
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>
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.
| Field | Type | Required | Description |
|---|---|---|---|
| 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
hashmodule scan, reports0and""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.
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 }{
"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
}
}
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.
| Key | Type | Description |
|---|---|---|
| module | string | Which analysis module produced this scan. |
| verdict | object | label and score. An object here, not the bare string the status route returns, so parse it as an object. |
| static | object | What 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. |
| dynamic | object | What 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. |
| intel | object | What 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. |
| iocs | bounded list | Pairs of {"type", "value"}, defanged, capped at 100. |
| run_integrity | object | Whether 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_id | integer | The scan's own id, the same one you polled. |
| status | string | The 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. |
| tags | bounded list | Analyst-facing labels the platform assigned, capped at 32. |
| submitted_at | string | ISO 8601 with an offset, or null. |
| finished_at | string | ISO 8601 with an offset, or null. |
| sha256 | string | The digest of the analysed bytes. |
| size | integer | Byte length, or 0 for a scan that carried no bytes of its own. |
| mime | string | Detected media type, or "" for a scan that carried no bytes of its own. |
"dropped_files": {
"items": [ ... ],
"returned": 100,
"total": 417,
"truncated": true
}
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_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 answer405with anAllowheader, because this revision has no SSE stream to open and no session to tear down. A request that carries anOriginheader is answered403unless 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 with202and 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.
| Header | Required on | Must equal |
|---|---|---|
| MCP-Protocol-Version | Every request | params._meta["io.modelcontextprotocol/protocolVersion"] |
| Mcp-Method | Every request | method |
| Mcp-Name | tools/call | params.name |
| Key | Type | Value |
|---|---|---|
| io.modelcontextprotocol/protocolVersion | string | Must be 2026-07-28. Any other value is refused, and the refusal names the versions the server supports. |
| io.modelcontextprotocol/clientCapabilities | object | Required, 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.
| code | HTTP | Meaning |
|---|---|---|
| -32700 | 400 | The body is not valid JSON. |
| -32600 | 400 | Not 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). |
| -32601 | 404 | Method not found. Only three methods exist. |
| -32602 | 400 | Invalid params: a missing or malformed _meta key, a missing name, non-object arguments, or an unknown tool. |
| -32603 | 500 | Internal error. Deliberately opaque. |
| -32020 | 400 | Header mismatch: a required mirrored header is missing or disagrees with the body. |
| -32022 | 400 | Unsupported protocol version. The data object names what is supported. |
| -31001 | 401 | Unauthorized: no token, a token that does not verify, or a plan without MCP access. |
| -31002 | 403 | Forbidden: a valid token without the scope a non-tools/call method costs. |
{"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}
-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_scanat all. - tools/call
- Runs one tool. The result carries both a serialised text block and a
structuredContentobject, plus anisErrorflag.
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
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.
| Name | Type | Required | Description |
|---|---|---|---|
| sha256 | string | required | Exactly 64 hex characters. |
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{
"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
| Field | Type | Cap | Object 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
| Field | Type | Cap | Object 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
| Field | Type | Cap | Object 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
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.
| Name | Type | Required | Description |
|---|---|---|---|
| scan_id | integer | required | The scan's numeric id. A decimal string is accepted too. |
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 }structuredContent here is the report object with the same
keys the REST report route returns, without the report wrapper.
read
search_indicator
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.
| Name | Type | Required | Description |
|---|---|---|---|
| indicator | string | required | The exact value, up to 512 characters. Defanged forms are accepted. |
| type | string | optional | Narrows the search. One of ip, domain, url, md5, sha1, sha256, imphash, mutex, registry, filepath, email, ja3, ja4, user_agent. |
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 }{
"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
}
}
ioc_type and
ioc appended. Results are capped at 50 and total reports the true count.
read
poll_scan
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.
| Name | Type | Required | Description |
|---|---|---|---|
| scan_id | integer | required | The scan's numeric id. |
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)
submit
submit_scan
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.
| Name | Type | Required | Description |
|---|---|---|---|
| module | string | required | One of hash, url, command, package. url and package detonate with internet access and are refused on a plan without it. |
| target | string | required | The digest, URL, command line or package specifier, matching the chosen module. Up to 2048 characters. |
| private | boolean | optional | Keeps the scan off the public corpus. Free plans cannot make a scan private and this is ignored for them. |
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{
"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
}
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
fileand 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-28as 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.