API
Guide to the VDB REST API.
Overview
VDB exposes an OSV-compatible vulnerability database plus AI-era signals (slopsquatting, MCP servers, model artifacts) as a public API. All responses are JSON, encoded as UTF-8.
| Base URL | https://vdb.ai.kr |
| Version | /v1/ |
| Content-Type | application/json |
| OpenAPI | openapi.json |
Auth policy
All endpoints work without auth — up to 10 cumulative calls per IP. After that, get a free API key (an agent can request one by email — see below). Authenticated calls are metered per account, not per IP, so users behind a shared egress (an AI provider, office NAT, or campus network) don't rate-limit each other. Search-engine crawlers are never rate-limited.
| Area | Auth | Example |
|---|---|---|
| Read · search · lookup | none | GET /v1/vulns/{id} · GET /v1/search |
| AI signals (read · bulk check) | none | GET /v1/ai/slopsquatting |
| SBOM scan | none | POST /v1/sbom/scan |
| Bug reports (account-bound) | API key | POST /v1/bug-reports |
| Admin | API key + is_admin | /v1/admin/* |
Traffic limits / abuse defence
VDB ignores normal usage entirely and only catches bots / scraping / burst attacks. Real users almost never hit a cap; the moment traffic spikes past these numbers, it's almost always automation.
1) Unauthenticated — 10 lifetime calls per IP
Without an Authorization header, an IP can make up to 10 cumulative calls. No monthly reset — one-shot trial. Call #11 returns a 429 whose body tells an agent how to get a free key by email (POST /v1/auth/request-key). Once you send Authorization: Bearer vdb_…, you're metered per account instead.
2) Minute / hour / day windows
Requests are capped per rolling minute, hour, and day on ONE axis, chosen by identity: anonymous traffic is capped per IP, authenticated traffic per account. The minute window catches bursts — bots fire hundreds of requests per minute and trip it long before the hour/day caps. Real users (AI agents, CI bursts, regular devs) stay well below all three. Page reads and search-engine crawls are never metered.
| Scope | Per min | Per hour | Per day | Env var |
|---|---|---|---|---|
| Per IP (anonymous) Shared-egress tolerant | 60 | 1,000 | 8,000 | VDB_RL_IP_PER_MIN VDB_RL_IP_PER_HOUR VDB_RL_IP_PER_DAY |
| Per account (vdb_ key) Your key, your limit | 120 | 3,000 | 20,000 | VDB_RL_USER_PER_MIN VDB_RL_USER_PER_HOUR VDB_RL_USER_PER_DAY |
Reference: real usage vs limits — An AI-agent coding session runs 5–10 calls/minute and 30–100/hour on average. The per-IP minute cap is 300 — sized so a shared AI-provider egress carrying many anonymous users isn't mistaken for a scraper. A full SBOM scan of 1000 packages is one call. A single host sustaining 5+ RPS for a full minute is what trips it; authenticated heavy users are bounded by their own account cap instead, never by a neighbour on the same IP.
3) Block durations
Minute cap exceeded → 10 min block (burst defence). Hourly cap → 1h block. Daily cap → 24h block. Response: 429 with reason, axis, and expires_at in the body (anonymous 429s also carry the request-key URL). Blocks apply only to metered calls — they never refuse page reads or crawls, and an IP block never affects an authenticated account on the same network.
4) The 429 tells the agent what to do
When the limit is hit the 429 body is machine-actionable: reason, axis, signup_url, and — for anonymous callers — request_key_url, plus a human-readable message in the caller's language. An AI agent can read it, ask the user for an email, POST it to /v1/auth/request-key, and keep going — the key is emailed, no dashboard visit needed.
5) Manual admin blocks
Admins can manually block an IP/account from /admin/blocks. Those rows are tagged reason=manual.
First call
The simplest unauthenticated call:
curl https://vdb.ai.kr/v1/vulns/GHSA-29mw-wpgm-hmr9Look up by package:
curl -X POST -H "Content-Type: application/json" \
-d '{"package":{"purl":"pkg:npm/lodash"},"version":"4.17.20"}' \
https://vdb.ai.kr/v1/queryGet an API key
- **By email (no password):** `POST /v1/auth/request-key` with `{"email":"you@example.com"}`. We email the key to that address — it's never returned in the response, so an AI agent calling on your behalf never sees the secret. This is the path the rate-limit 429 points agents to.
- **With a password:** sign up with email + password; your first key is shown once and you manage keys from your account.
- Created an account via email? Set a web-login password from the link in that email (or request a fresh one). Your key keeps working regardless.
Bearer header
Send the key in the Authorization header:
Authorization: Bearer vdb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxcurl example:
curl -H "Authorization: Bearer vdb_xxxxx" \
https://vdb.ai.kr/v1/auth/meManage / revoke keys
From /account you can:
- Mint a new key (with an optional name)
- List your keys (prefix · last-used timestamp)
- Revoke a specific key (effective immediately)
Same operations via API:
# new key
POST /v1/auth/keys/new
{ "name": "ci" }
# list
GET /v1/auth/keys
# revoke
POST /v1/auth/keys/revoke
{ "key_id": "<uuid>" }Auth API (signup/login/verify)
Wire-level endpoints for the lifecycle the web UI uses — documented so SDKs and CLI tools can talk to them directly. Email verification is mandatory at signup.
# Get a free key BY EMAIL — no password needed. The funnel the 429 points
# agents to. The key is emailed to the address; it is NEVER in the response.
# Generic 200 either way (anti-enumeration).
POST /v1/auth/request-key
{ "email": "you@example.com", "email_consent": true }
# Set a web-login password via the single-use link from that email.
POST /v1/auth/set-password
{ "token": "<from the email link>", "password": "..." }
# Sign up WITH a password — sends a verification email (mandatory).
POST /v1/auth/signup
{ "email": "you@example.com", "password": "...", "email_consent": true }
# Click the link in the email → web app calls this:
GET /v1/auth/verify?token=<token> # one-time; mints the first API key
# Resend the verification email (anti-enumeration: same 200 either way).
POST /v1/auth/resend-verification
{ "email": "you@example.com" }
# Log in → sets a web session cookie. Use Bearer + an API key for programmatic.
# A passwordless (email-funnel) account gets 403 password_not_set until it
# sets one via /v1/auth/set-password.
POST /v1/auth/login
{ "email": "you@example.com", "password": "..." }
# Which accounts share this IP? Read-only audit signal. Returns { ip_hash, binding }.
GET /v1/auth/ip-bindingMetered per account, not per IP — authenticated calls are counted against the key (account), so users behind a shared egress (an AI provider, an office NAT) don't rate-limit each other. Without a key you get 10 free trial calls per IP, then request an emailed key via /v1/auth/request-key. (The old 1:1 "IP binding + 7-day cooldown" enforcement collided with shared egress and is now off by default.)
Vulnerabilities
Single lookup
GET /v1/vulns/{id}VDB ID, CVE, GHSA or EUVD all work. Response is OSV 1.6 + vdb_signals extension.
Match by package (OSV-compatible)
POST /v1/query
{
"package": { "purl": "pkg:npm/lodash" },
"version": "4.17.20"
}Recently added
GET /v1/recent?limit=12Search
GET /v1/search?q=lodash&limit=30Full-text search across id / alias / summary / details. Backed by a PostgreSQL tsvector + GIN index.
SBOM / dependency-file scan
Upload an SBOM, lockfile, or manifest — VDB auto-detects the format, parses it, and matches each component against the vulnerability DB. Every finding includes a Level 1 upgrade command and a Level 2 affected-functions list.
Request
POST /v1/sbom/scan
Authorization: Bearer vdb_xxxxx # optional — anonymous gets 3 lifetime scans/IP
Content-Type: multipart/form-data
file=@<filename> # single file, max 20 MBAnonymous callers get 3 lifetime scans per IP so you can confirm the response shape with a real lockfile before signing up. After that you'll see a 401 with signup_url in the body — get a free key at /signup for unmetered access. Cap is tighter than check-packages (10/IP) because SBOM parse + bulk vuln JOIN is materially more expensive per call.
Auto-detected formats:
- CycloneDX (JSON/XML), SPDX 2.3+ JSON
- npm:
package.json,package-lock.json,yarn.lock,pnpm-lock.yaml - Python:
requirements.txt,Pipfile.lock,pyproject.toml - Go:
go.mod,go.sum - Rust:
Cargo.lock· Ruby:Gemfile.lock· PHP:composer.lock - Dart / Flutter:
pubspec.yaml,pubspec.lock - Excel (
.xlsx) · CSV/TSV (auto-detected headers)
Example call
curl -H "Authorization: Bearer $VDB_API_KEY" \
-F file=@package-lock.json \
https://vdb.ai.kr/v1/sbom/scanResponse (200 OK)
{
"sbom_format": "package-lock.json",
"components_total": 127,
"components_matched": 14,
"components_covered": 23,
"components_unknown": 104,
"coverage_ratio": 0.181,
"summary": {
"critical": 1, "high": 5, "medium": 8,
"low": 0, "none": 0,
"kev": 2, "slop": 0
},
"vulnerabilities": [
{
"id": "GHSA-29mw-wpgm-hmr9",
"purl": "pkg:npm/lodash",
"version": "4.17.20",
"summary": "Command injection in lodash",
"severity_bucket": "high",
"severity_score": 7.4,
"fixed_in": ["4.17.21"],
"kev": false, // CISA Known-Exploited list membership
"epss": 0.00073, // FIRST.org 30-day exploit probability (0..1)
"slop_risk": null,
// ── Level 1 — copy-pasteable upgrade hint ──────────────
"remediation": {
"type": "upgrade",
"ecosystem": "npm",
"name": "lodash",
"fixed": "4.17.21",
"command": "npm install lodash@4.17.21",
"manifest": "\"lodash\": \"^4.17.21\"",
"rationale": "Upgrade lodash to 4.17.21 or newer via npm."
},
// ── Level 2 — function-level impact ───────────────────
"affected_functions": [
{ "path": null, "symbol": "template" },
{ "path": null, "symbol": "templateSettings" }
]
}
],
// ── Anon trial counter (omitted entirely when authenticated) ──
"trial": {
"used": 1, // count BEFORE this call: now 1/3 consumed
"limit": 3, // lifetime per source IP
"remaining": 2,
"reset_at": null // lifetime quota — never resets
}
}Field reference
| Field | Description |
|---|---|
| sbom_format | Auto-detected input format (e.g. cyclonedx-json, package-lock.json). |
| components_total / components_matched | Total components / how many matched a vulnerability. |
| components_covered / components_unknown / coverage_ratio | How many SBOM components VDB recognises (across vulns / MCP / models / datasets), how many it doesn't, and the ratio. A value under 0.2 means most of the SBOM wasn't checked — surface that to the user; it's not a clean bill of health. |
| summary | Severity distribution of vulns found + KEV/slop counts. |
| vulnerabilities[].severity_bucket | critical / high / medium / low / none. Derived from CVSS v3 with GHSA fallback. |
| vulnerabilities[].kev / .epss | KEV (CISA Known Exploited Vulnerabilities — boolean) + EPSS (FIRST.org 30-day exploit probability, 0..1, daily). When kev=true the gate refuses regardless of CVSS bucket; epss ≥ 0.5 is treated the same way. Both signals are upserted into ai_signals by the daily kev-epss collector (05:25 UTC) and joined into the response. |
| vulnerabilities[].fixed_in | All fixed versions extracted from OSV affected[].ranges (deduped). |
| vulnerabilities[].remediation | Ecosystem-specific upgrade guidance — object or null. |
| .type | "upgrade"or"no-fix-available". The latter means OSV doesn't carry any fixed marker yet. |
| .fixed | Recommended minimum upgrade version — the lowest value in fixed_in. |
| .command | A command you can paste into a shell. null for unsupported ecosystems. |
| .manifest | One line to drop into the lockfile/manifest — handy for PR automation. |
| .rationale | One-line description (for inline UI display). |
| vulnerabilities[].affected_functions | Affected functions extracted from OSV's ecosystem_specific / database_specific. Each entry: { path, symbol }. path is only populated for ecosystems that record import paths (e.g. Go vulndb). An empty array means the advisory didn't ship function-level data. |
| vulnerabilities[].slop_risk | Slopsquatting risk tier (high/medium/low) or null. |
Recommended usage
To gate PRs in CI, block the merge when summary.critical + summary.high > 0 and quote each vulnerabilities[].remediation.command in the PR body. If affected_functions is non-empty, add a grep step that checks whether your code actually calls those symbols — that's the highest-accuracy filter.
Errors
400— empty file / parse failure / malformed multipart payload.413— file larger than 20 MB.
AI signals (4-pillar: packages · MCP · models · datasets)
0) Self-discovery manifest
Single JSON an AI tool can fetch once to learn every endpoint, decision policy, and the prompt-injection guardrail. Prompts only need to know this one URL.
GET https://vdb.ai.kr/v1/ai/manifestSlopsquatting candidate list
GET /v1/ai/slopsquatting?ecosystem=npm&limit=50Bulk check — packages, models, and datasets (DB + realtime registry)
POST /v1/ai/check-packages
{
"packages": [
"pkg:npm/%40babel/core@7.24.0", // exact version (purl, %40 = @)
"pkg:npm/express@^4.19.2", // semver range — server resolves
"pkg:pypi/requests@>=2.0,<3.0", // PEP 440 specifier — server resolves
"pkg:pub/dio@5.4.0", // Dart / Flutter via pub.dev
"pkg:huggingface/BAAI/bge-large-en-v1.5", // AI model (Hugging Face)
"pkg:data/squad", // training dataset
"pkg:mcp/anthropic/filesystem" // MCP server (trust + scopes)
],
"probe_registry": true // default. false → DB only (skip realtime probe).
}Input auto-normalisation — if you send OSV-style purls (pkg:crates.io/..., pkg:rubygems/..., pkg:go/..., pkg:packagist/...) we translate them to purl-spec canonical types (cargo, gem, golang, composer) before matching. Response keeps your original in input and the normalised form in purl.
For each item: (1) slop / model / dataset DB lookup + (2) live npm · PyPI · crates · Go · Dart · Hugging Face probe, run in parallel. If either signal is suspicious, matched=true.
Supported ecosystems: npm, PyPI, crates.io, Go modules, pub.dev (Dart/Flutter), Hugging Face (models + datasets). The version slot accepts exact versions or ranges (^4.19.2, ~1.2, >=2.0,<3.0, 1.2.x, ||). Ranges are resolved server-side for npm and PyPI and the chosen concrete version is returned in resolved_version. AI-model and dataset entries get an extra model / dataset object on the response with weights_format, license, and PII signals.
{
"results": [
{
"input": "pkg:npm/express@^4.19.2",
"purl": "pkg:npm/express@^4.19.2",
"version": "4.21.2", // what we evaluated against
"requested_version": "^4.19.2", // raw caller input
"resolved_version": "4.21.2", // null if range unresolvable
"matched": false,
"risk": "low",
"flags": [],
"registry": { "exists": true, "age_days": 482, "risk_hint": "low" },
"vulnerabilities": []
},
{
"input": "pkg:npm/this-name-does-not-exist@1.0.0",
"purl": "pkg:npm/this-name-does-not-exist@1.0.0",
"matched": true,
"risk": "not_found", // ← new
"flags": [],
"registry": {
"ecosystem": "npm",
"name": "this-name-does-not-exist",
"exists": false,
"risk_hint": "not_found",
"rationale": "Name does not exist on the npm registry — almost certainly an LLM hallucination. Attackers commonly squat hallucinated names; refuse without explicit user confirmation."
}
},
{
"input": "pkg:npm/react-helper-v2@1.0.0",
"risk": "high",
"registry": {
"exists": true,
"age_days": 3,
"risk_hint": "high",
"rationale": "Registered only 3 day(s) ago — fresh-registered names that LLMs already recommend are a classic slopsquatting pattern."
}
},
{
"input": "pkg:mcp/anthropic/filesystem",
"purl": "pkg:mcp/anthropic/filesystem",
"risk": "medium", // ← scopes are orthogonal to risk
"mcp": {
"trust_tier": "community", // official / partner / community / unverified
"scopes": ["fs:read", "fs:write", "exec"],
"risk_score": 62,
"risk_notes": "Holds fs:write + exec — capable of arbitrary code execution on the host.",
"scope_drift": { // populated only if changed within 90 days
"added": ["exec"],
"removed": [],
"changed_at": "2026-05-18T09:32:00Z",
"previous": ["fs:read", "fs:write"]
}
}
},
{
// Log4Shell — illustrates the KEV + EPSS exploit-evidence signals.
// vulnerabilities[] entries carry the same kev / epss fields as
// sbom-scan findings. Array is pre-sorted KEV → EPSS → CVSS so
// vulnerabilities[0] is always the right one to surface.
"input": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"version": "2.14.1",
"matched": true,
"risk": "high",
"vulnerabilities": [
{
"id": "GHSA-jfh8-c2jp-5v3q",
"summary": "Remote code injection in Log4j (Log4Shell)",
"severity_bucket": "critical",
"severity_score": 10.0,
"fixed_in": ["2.15.0"],
"kev": true, // CISA-confirmed exploited
"epss": 0.944 // 94.4% exploit probability (FIRST)
}
]
}
],
"trial": {
"limit": 10, // 10 lifetime calls per source IP (anon only)
"remaining": 7,
"reset_at": null // lifetime quota — never resets. Sign up to bypass.
}
}MCP scope_drift — populated only when permissions changed within the last 90 days. A non-empty added is a classic maintainer-takeover signal; require explicit user confirmation regardless of risk grade. If nothing drifted, the field is absent (not null).
KEV / EPSS — exploit-evidence signals. Every vulnerabilities[] entry carries kev (boolean — CISA Known Exploited listing) and epss (0..1 — FIRST 30-day exploit probability). kev=true or epss ≥ 0.5 means refuse regardless of severity bucket — catches the "low CVSS but already being exploited" case automatically. The array is pre-sorted KEV → EPSS → CVSS, so vulnerabilities[0] alone surfaces the top threat.
The trial block is only present on anonymous calls. reset_at: null means it's a lifetime quota (not daily) — call GET /v1/ai/trial-status for the remaining count without consuming a call.
Return-visit watchlist — since_last_visit — for any purl this IP checked in the last 30 days, surfaces vulnerabilities published since that check. Anon-only; no signup, no extra call, no watchlist registration required. Field is omitted entirely when the list is empty.
Shape: { lookback_days: 30, total_alerts: N, alerts: [{ purl, checked_at, new_vulnerability: { id, summary, modified, severity } }] }
Recommended action per risk grade
| risk | Recommended action |
|---|---|
| not_found | Refuse. Registry confirmed missing — almost certainly an LLM hallucination. Tell the user the name was made up and ask for the intended real package. |
| high | Refuse. Quote reason / advisory_url. Suggest a safer alternative. |
| medium | Warn; require explicit user confirmation before proceeding. |
| unknown | VDB couldn't verify (distinct from not_found). Tell the user and ask before proceeding. |
| low | Proceed normally. |
MCP server registry
GET /v1/ai/mcp-servers
GET /v1/ai/mcp-servers/{id} # id = "owner/name" OR "mcp:owner/name" OR "pkg:mcp/owner/name"Prefer /v1/ai/check-packages for MCP lookups — same gate logic + scope_drift + trial counter in one response. Direct GET is for browsing/debugging. Unknown servers come back 200 with trust_tier: "unverified" + metadata.source: "not_in_registry" so agents don't have to special-case 404s.
The response object mirrors the mcp field on the check-packages response — trust_tier, scopes, risk_score, and scope_drift (when applicable).
The same capabilities are exposed natively as an MCP server — add the vdb/mcp:dev container as a stdio MCP server in Claude Desktop / Cursor and the tools (vdb_check_packages, vdb_lookup, vdb_search, …) appear natively. Install instructions are in the manifest's mcp_server field.
AI model registry (Hugging Face)
GET /v1/ai/models # list / filter by provider, license
GET /v1/ai/models/{purl} # purl = pkg:huggingface/owner/name
# example
curl https://vdb.ai.kr/v1/ai/models?provider=huggingface&limit=20
curl https://vdb.ai.kr/v1/ai/models/pkg:huggingface/BAAI/bge-large-en-v1.5Model cards collected from Hugging Face and elsewhere. Exposes weights_format (pickle vs safetensors), license, sha256, download counts, risk_score, and risk_notes. Call before pulling a model to catch code-execution risk (pickle), license violations, or signature mismatches.
{
"id": "pkg:huggingface/BAAI/bge-large-en-v1.5",
"display_name": "BAAI/bge-large-en-v1.5",
"provider": "huggingface",
"framework": "pytorch",
"license": "mit",
"weights_format": "safetensors", // pickle | safetensors | gguf | …
"sha256": "…",
"downloads": 1843201,
"risk_score": 0.05,
"risk_notes": null,
"metadata": { /* raw HF model-card fields */ }
}weights_format=="pickle" models execute arbitrary code on load (torch.load gadget chain). Prefer the safetensors variant when available. If license is non-commercial/research-only/other/null, review before commercial use.
Training-dataset registry
GET /v1/ai/datasets # list / filter by provider, license
GET /v1/ai/datasets/{purl} # purl = pkg:data/name or pkg:data/owner/name
# example
curl https://vdb.ai.kr/v1/ai/datasets?provider=huggingface&limit=20
curl https://vdb.ai.kr/v1/ai/datasets/pkg:data/squadDataset cards from Hugging Face, Kaggle and others. Exposes file_formats, license, task, downloads, risk_score, and risk_notes (including PII flags). Check license and personal-information risk before fine-tuning on, redistributing, or commercialising a dataset.
{
"id": "pkg:data/squad",
"display_name": "rajpurkar/squad",
"provider": "huggingface",
"task": "question-answering",
"file_formats": "parquet,json",
"license": "cc-by-sa-4.0",
"downloads": 412330,
"risk_score": 0.10,
"risk_notes": null, // e.g. "pii:flagged" when HF tags include PII signals
"description": "Stanford Question Answering Dataset"
}Bug reports (auth required)
POST /v1/bug-reports
Authorization: Bearer vdb_xxxxx
Content-Type: application/json
{
"title": "Search result ordering looks off",
"description": "Repro: ... / Expected: ... / Actual: ...",
"category": "ui"
}Categories: ui · data · api · suggestion · other
GET /v1/bug-reports/me # list my reportsMeta / changelog
Lightweight public endpoints so external dashboards can pull build info, headline counts, /changelog entries, or the 30-day collection activity without scraping HTML. No auth required.
GET /v1/version # build + commit info
GET /v1/stats # headline counts (vulns, mcp, …)
GET /v1/changelog?limit=200 # curated + auto-milestone entries
GET /v1/activity/recent # 30-day sanitised collection activity (day × source)Admin (is_admin required)
All admin endpoints require Authorization: Bearer plus is_admin=true on that user.
GET /v1/admin/users
PATCH /v1/admin/users/{id} # { "is_admin": true }
GET /v1/admin/bug-reports[?status_filter=open]
PATCH /v1/admin/bug-reports/{id} # status / triage_note
GET /v1/admin/disclosures[?status_filter=received]
GET /v1/admin/disclosures/{id} # PoC included
PATCH /v1/admin/disclosures/{id} # status / internal_notes / vuln_id
GET /v1/admin/traffic?hours=24[&source=user|internal|anon]
GET /v1/admin/collector-runs
GET /v1/admin/collector-health # 24h health + watermarks + DB counts
GET /v1/admin/anon-insights # funnel + cohort retention + conversion
POST /v1/admin/changelog # { date, tag, title_ko, title_en, body_ko, body_en, visibility? }Swagger UI
Interactive — call and test endpoints directly from the browser. Everything is grouped by tag (vulnerabilities, sbom, ai-signals, auth, bug-reports, disclosures, admin, meta).
ReDoc
OpenAPI 3.1 JSON
curl https://vdb.ai.kr/openapi.json > vdb-openapi.jsonEditor extension — VS Code & Cursor
Prefer not to call the API by hand? The official **VDB** extension wraps this same POST /v1/ai/check-packages endpoint and surfaces the results inline as you edit. Open or save a package.json, requirements.txt, Cargo.toml, go.mod, pubspec.yaml, or an MCP config and risky dependencies are underlined right in the editor — slopsquatting as errors, known CVEs with a one-click upgrade to a safe version.
It also generates a CycloneDX SBOM from your workspace and scans it in one step, and checks the npm/PyPI package behind each MCP server. Works in VS Code, Cursor, Windsurf, and VSCodium (via Open VSX). Only package names and versions are sent — never your source.
Error responses
Errors always look like:
{ "detail": "<message>" }| Status | Meaning |
|---|---|
| 400 | Request body or parameter validation failed |
| 401 | Bearer token missing or invalid |
| 403 | Admin privilege required |
| 404 | Resource not found |
| 409 | Conflict (e.g. duplicate email) |
| 413 | Upload too large (SBOM > 20 MB) |
| 429 | Rate limit exceeded |
| 500 | Server error — please report it |