API REFERENCE

One path per package version

Every package version on npm has exactly one address in this API. Read it with GET to get whatever is known. Write to it with POST to put it in the sandbox queue. Same path, both directions, so nothing has to be looked up twice.

https://api.snappyfeet.org

Base URL. Every path below hangs off it. HTTPS only, and requests over plain HTTP are refused rather than redirected.

Authorization: Bearer <key>

Required on every request. A free key reads verdicts. A paid key can also queue analyses.

Content-Type: application/json

On any request with a body. Responses are always JSON, including errors.

THE PACKAGE PATH

GET reads it, POST queues it

A GET never costs you an analysis. If the version has been through the sandbox you get the verdict and the evidence immediately. If nobody has ever run it you get 404 with "verdict": "unknown", and it is then up to you whether to spend an analysis on it.

GET /api/package/npm/{name}/{version}200 OK
curl -H "Authorization: Bearer $COHEN_KEY" \
  https://api.snappyfeet.org/api/package/npm/left-pad/1.3.0

{
  "ecosystem":     "npm",
  "name":          "left-pad",
  "version":       "1.3.0",
  "verdict":       "clean",
  "analysed_at":   "2026-08-31T09:14:02Z",
  "engines":       [ "dynamic", "static" ],
  "sources":       [ "cohen", "osv", "npm-advisory" ],
  "integrity":     "sha512-nbE1WxOT...",
  "findings":      [],
  "evidence":      "/api/package/npm/left-pad/1.3.0/evidence"
}
POST /api/package/npm/{name}/{version}202 ACCEPTED
curl -X POST -H "Authorization: Bearer $COHEN_KEY" \
  https://api.snappyfeet.org/api/package/npm/polymarket-stake-mathss/3.5.2

// queued. one deep analysis has come off your monthly allowance.
{
  "job":           "job_01HXY5K9PNQE3",
  "state":         "queued",
  "queue_position": 4,
  "eta_seconds":   180,
  "poll":          "/api/job/job_01HXY5K9PNQE3",
  "allowance": { "used": 137, "limit": 150, "resets": "2026-10-01T00:00:00Z" }
}

// the same POST once the run has finished, 200 OK, no charge
{
  "verdict":  "review",
  "cached":   true,
  "findings": [ "credentials_read_then_archived", "outbound_outside_registry" ]
}

Posting a version that is already known returns 200 with the cached verdict and charges nothing. Posting one that is already in the queue returns the existing job rather than creating a second. You cannot accidentally pay twice for the same version.

SCOPED NAMES

A scope contains a slash, so encode it

This is the one place the path shape bites. An npm scoped name such as @types/node has a slash in it, which would otherwise read as another path segment. Percent encode it as %2F. The leading @ is safe either raw or as %40.

/api/package/npm/@types%2Fnode/20.11.5

Correct. One name, one version, two segments.

/api/package/npm/@types/node/20.11.5

Rejected with 400. Three segments, so the version is ambiguous. The error names the fix rather than guessing.

/api/package/npm/%40types%2Fnode/20.11.5

Also correct, if your client encodes aggressively.

ENDPOINTS

Everything, in one list

GET/api/package/npm/{name}/{version} Whatever is known about one version. Free, never metered.
POST/api/package/npm/{name}/{version} Queue that version for a deep analysis. Costs one analysis, unless it is already known or already queued.
GET/api/package/npm/{name}/{version}/evidence The full trace behind a verdict: syscalls, process tree, connections, file hashes, entropy. Paid keys only.
GET/api/package/npm/{name} Every analysed version of a package, newest first. Paginated.
POST/api/package/npm Bulk submit. Send a whole dependency tree in one call. This is what Mantis uses.
GET/api/job/{job} State of one queued analysis: queued, running, done or failed.
GET/api/observatory/records The Observatory feed. Every analysed version with its verdict, from a cursor.
GET/api/observatory/indicator/{type}/{value} Reverse lookup. Give it a C2 host or a file hash, get every package version that touched it.
POST/api/webhook Register a callback for when a package you watch flips to review.
GET/api/key Your allowance, usage and reset date. Never counted against anything.

BULK SUBMIT

A whole tree in one request

Posting 1,284 packages one at a time works, and it is a waste of everybody's time. Send the tree instead. The response splits it into what is already known and what had to be queued, so you can act on the known part immediately.

POST /api/package/npm207 MULTI STATUS
curl -X POST -H "Authorization: Bearer $COHEN_KEY" \
  -H "Content-Type: application/json" \
  -d @tree.json https://api.snappyfeet.org/api/package/npm

// request body
{
  "versions": [ "left-pad@1.3.0", "@types%2Fnode@20.11.5", "slugify-fast@2.1.0" ],
  "queue_unknown": true,
  "skip_private":  true
}

// response
{
  "counts": { "clean": 1271, "review": 2, "queued": 11, "skipped": 0 },
  "review": [
    { "name": "polymarket-stake-mathss", "version": "3.5.2",
      "findings": [ "credentials_read_then_archived" ] }
  ],
  "queued":    [ { "name": "tiny-parse", "version": "0.2.1", "job": "job_01HXY..." } ],
  "allowance": { "used": 148, "limit": 150 }
}

Set queue_unknown to false to get a pure read: you are told what is unknown without spending anything on it. With skip_private set, names that do not resolve on the public registry are counted as skipped and never leave your side of the wire.

THE VERDICT OBJECT

Three states, and only three

REVIEW

The sandbox saw behaviour a human should look at. Never a claim of malice, always a pointer to evidence. findings[] is populated and evidence is fetchable.

CLEAN

It ran, and nothing correlated. Recorded with a timestamp, because clean in August is not clean in October if the version was republished.

verdict

One of clean, review or unknown. Never a score, never a percentage. A number would imply a confidence nobody can honestly give.

findings[]

Stable string identifiers, not prose, so you can match on them. Empty on a clean verdict.

engines[]

Which engines produced the answer. A database only hit returns an empty array and a sources list instead.

analysed_at

When the sandbox ran, not when you asked. Compare it against the version publish date if freshness matters to you.

integrity

The published integrity hash of the tarball that was actually analysed. If it differs from your lockfile, you and the sandbox looked at different bytes.

evidence

A path, not an object. The trace is large, so it is a second request rather than a bloated first one.

OBSERVATORY FEED

Cursor forward, or ask an indicator backward

The feed is a different product on a different meter: requests, not analyses. It reads the same records the scanner writes, so nothing is recomputed for it.

GET /api/observatory/indicator/host/45.61.136.12200 OK
curl -H "Authorization: Bearer $COHEN_KEY" \
  "https://api.snappyfeet.org/api/observatory/indicator/host/45.61.136.12"

{
  "indicator": { "type": "host", "value": "45.61.136.12" },
  "first_seen": "2026-08-31T09:14:22Z",
  "packages": [
    { "name": "polymarket-stake-mathss", "version": "3.5.2", "by": "node postinstall.js" },
    { "name": "fast-json-parse-native",  "version": "0.4.1", "by": "curl" }
  ]
}
?since={cursor}

Opaque cursor from the previous page. Use this rather than a timestamp: it is stable across reruns and will not skip a record written out of order.

?verdict=review

Filter the feed. Omit it to get clean versions too, which is what makes the feed usable as a baseline.

indicator/{type}

host, domain, sha256 or path. Every one resolves back to the package and the process that produced it.

ERRORS AND LIMITS

What each status actually means

Errors are JSON with a stable error code and a message written for a person. The code is what you should branch on.

200

Known. On a POST this means it was already analysed and you were not charged.

202

Queued. An analysis has come off your allowance. Poll the poll path.

207

Bulk submit succeeded with a mixed outcome. Read counts, not the status line.

400

Malformed path or body. Most often an unencoded scope. The message names the segment at fault.

401

Missing or invalid key.

403

Your key cannot do this. Either the allowance is spent, or the endpoint needs a paid key.

404

Two different situations, distinguished by the body: verdict: "unknown" means we have never analysed it, error: "not_in_registry" means npm has never heard of it either.

429

Rate limited. Retry-After is always set. Back off rather than retrying tightly.

503

The sandbox is saturated. Reads keep working, queueing does not, and nothing is charged.

A free key reads every verdict in the database

contact@snappyfeet.org