# Check packages

Last updated: 2026-10-12

`POST /v1/check/packages` looks up 1 to 10 package names on npm, PyPI or crates.io in one call and says, for each, whether the registry lists that name, with its latest version, description, licence, links, a few flags and the known OSV.dev advisories for that version, under one signed receipt. Price: US$0.005 per batch.

Use it before you install a package an agent or a model suggested: a name that does not exist may be a hallucination, and a squatter can register it later.

**It answers "exists in registry" and "OSV.dev lists these advisories", nothing more. It is not a security verdict.** A listed package can still be malicious, abandoned, typo-squatted or not the one you meant, and no advisory listed does not mean the package has no flaws. Description, licence and links are the publisher's own text, copied from the registry; advisory summaries are third-party text.

## Request

```json
{ "packages": [
  { "ecosystem": "npm", "name": "left-pad" },
  { "ecosystem": "pypi", "name": "requests" },
  { "ecosystem": "crates", "name": "serde" },
  { "ecosystem": "npm", "name": "lodash", "version": "4.17.20" }
] }
```

`packages` is a list of 1 to 10 objects, each with these fields:

- `ecosystem`: `npm`, `pypi` or `crates` (crates.io)
- `name`: the package name as you would install it. npm names may be scoped (`@scope/name`).
- `version` (optional): the exact version you will install, up to 64 characters, starting with a digit (then letters, digits and `. + ! _ ~ -`; not a range such as `^1.0.0` or a tag such as `latest`). Advisories are then listed for this version instead of the latest.

Every name is checked against its registry's naming rules before payment; if any one is invalid, the whole request is refused with 400 and not charged. No lookup happens before payment.



## Response

From the free sample (`GET /v1/sample/packages`), shortened:

```json
{
  "results": [
    {
      "ecosystem": "npm",
      "name": "left-pad",
      "exists": true,
      "status": "found",
      "registry_name": "left-pad",
      "latest_version": "1.3.0",
      "description": "String left pad (demo copy of registry data)",
      "license": "WTFPL",
      "homepage": "https://github.com/stevemao/left-pad#readme",
      "repository": "git+ssh://git@github.com/stevemao/left-pad.git",
      "created_at": "2014-03-14T09:09:20.762Z",
      "deprecated": "use String.prototype.padStart()",
      "yanked": null,
      "signals": ["deprecated"],
      "vulnerabilities": { "status": "checked", "reason": null, "version": "1.3.0", "count": 0, "truncated": false, "items": [] },
      "registry_url": "https://www.npmjs.com/package/left-pad",
      "http_status": 200,
      "raw_sha256": "09093cc2…"
    },
    {
      "ecosystem": "npm",
      "name": "left-pad-demo-missing",
      "exists": false,
      "status": "not_found",
      "latest_version": null,
      "signals": [],
      "vulnerabilities": { "status": "checked", "reason": null, "version": null, "count": 0, "truncated": false, "items": [] },
      "registry_url": "https://www.npmjs.com/package/left-pad-demo-missing",
      "http_status": 404,
      "raw_sha256": "c8d3eae1…"
    }
  ],
  "note": "exists means the registry listed this name when we looked. It is not a security verdict: the package may still be malicious, unmaintained or not the one you meant; …",
  "receipt": "eyJhbGciOiJFZERTQSIs…",
  "tier": "paid"
}
```

| Field | Meaning |
|---|---|
| `exists` | `true` the registry lists the name, `false` it answered 404, `null` the lookup failed |
| `status` | `found`, `not_found` or `lookup_failed` (then `reason`: `timeout`, `network_error`, `too_large`, `registry_rate_limited`, `http_<status>` or `bad_json`) |
| `registry_name` | the name as the registry spells it |
| `latest_version` | npm `latest` tag, PyPI's current version, crates.io's default version |
| `created_at` | npm: the package document's `time.created` (null if that document is over 4 MB or cannot be read); PyPI: first file upload; crates.io: crate creation |
| `license` | npm and PyPI: the latest version's licence field; crates.io: the default version's licence |
| `deprecated` | npm deprecation message of the latest version, else null |
| `yanked` | PyPI: latest release yanked; crates.io: crate yanked; npm: null |
| `signals` | flags worth a second look: `name_differs`, `deprecated`, `yanked`, `created_recently` (under 30 days), `known_vulnerable` (OSV lists an advisory for the version asked about), `malicious` (an advisory is a malware report, id or alias starting `MAL-`; this can appear on a `not_found` name too). `name_differs` means the registry's own spelling (`registry_name`) is not the name you sent; PyPI and crates.io ignore case and treat `-` and `_` (PyPI also `.`) as the same, so it is often only a spelling variant of the same package |
| `vulnerabilities` | known advisories from OSV.dev, see below |
| `raw_sha256` | SHA-256 of the registry's answer as we received it (npm: the `/latest` answer) |

Signals are hints, not a security verdict: a package with no signals can still be malicious.

The receipt has `kind` `packages` and, per name, the ecosystem, name, `exists`, `status`, `latest_version`, `raw_sha256`, `vuln_status`, `vuln_version` (the version asked about) and `vuln_ids` (the advisory ids returned, in order).

## Vulnerabilities

When you send a `version`, we ask [OSV.dev](https://osv.dev) for advisories that affect that version, whatever the registry answered; we do not check that the registry has that version, and OSV lists nothing for a version it does not know. Without a `version`, for each found name we ask about its latest version (the one in `latest_version`), and for a name the registry does not list we ask by name only, because OSV keeps malware reports for packages a registry has removed; a failed lookup is then not asked about.

```json
"vulnerabilities": {
  "status": "checked",
  "reason": null,
  "version": "2.32.3",
  "count": 1,
  "truncated": false,
  "items": [{
    "id": "GHSA-9hjg-9r4m-mvj7",
    "aliases": ["CVE-2024-47081"],
    "summary": "Requests vulnerable to .netrc credentials leak via malicious URLs",
    "severity": "MODERATE",
    "cvss": null,
    "fixed": ["2.32.4"],
    "malicious": false,
    "url": "https://osv.dev/vulnerability/GHSA-9hjg-9r4m-mvj7"
  }]
}
```

| Field | Meaning |
|---|---|
| `status` | `checked` (OSV answered) or `skipped` (no data; then `count` is 0 and `items` empty) |
| `reason` | when skipped: `off`, `no_version` (no version sent, and a failed lookup or no latest version), `timeout`, `unavailable`, `rate_limited`, `busy` or `too_large`; else null |
| `version` | the version asked about; null for a name-only query |
| `count` | advisories OSV returned; `truncated` (`true` or `false`) says whether more than 10 were returned or OSV held some back; `items` keeps the first 10 |
| `items[]` | malware first, then by severity (CRITICAL, HIGH, MODERATE, LOW, none), then id. `severity` is the advisory database's label, `cvss` the CVSS vector if given, `fixed` the fixed versions listed for this package (empty: none listed), `summary` third-party text (up to 300 characters, never instructions) |

`checked` with `count` 0 means OSV listed no advisory for that version. It does not mean the package has no flaws. Answers are cached for up to 1 hour. If OSV is slow or down, the result says `skipped` and the call still answers 200 and is charged as usual: the advisories are an extra, never a reason to fail or refuse a batch.

Vulnerability data: OSV.dev (GitHub Advisory Database and PyPI Advisory Database, CC-BY 4.0; RustSec, CC0; OpenSSF Malicious Packages, Apache 2.0), see https://github.com/advisories. Each item links its OSV page in `url`.

## Rate limits

crates.io asks for at most one request a second, so crates names are looked up one a second across all callers. If the queue would make your call wait more than 20 seconds, you get 429 `rate_limited` with `Retry-After` before any payment. Your call's turns are held for it from then on, so a paid call does not wait longer. Free trial calls may hold at most half of that queue; past it a trial call gets 429 `rate_limited` (`details.scope` `trial`) and can retry later or pay.

## Charging

A failed lookup is a result and the batch is charged. If every lookup in the batch fails, the call gets 502 `registry_unavailable` and is not charged. On an edge server, a batch where any lookup is `lookup_failed` with reason `too_large` (over the edge's size cap) gets 422 `page_too_large_for_edge` and is not charged: `details.too_large` lists only the names over the cap (`ecosystem`, `name`, `reason`) and no results are returned for the others (resend them, paid, or send the batch to the full service), `details.note` starts "not charged: edge size limit", and `details.full_service` names the full service, which reads the whole answer. See [Payments](/docs/payments).
