Rate Limits
There is one enforced limit, and it is narrower than a tier table. This page states what is enforced, what the headers mean and how to behave around a 429. An earlier edition described public and authenticated tiers of 100 and 500 requests a minute; no such tiers exist and no credential raises any limit.
What is enforced
A single Cloudflare edge rule, applied before a request reaches the API:
| Paths | Limit |
|---|---|
Every path beginning /api/v1/sbom (/sbom, /sbom/scan, /sbom/time-travel, /sbom/formats) and /api/v1/cve/:id/ai-assessment | 2 requests per 10 seconds per address, enforced per Cloudflare location |
These are the routes where one call fans out to an upstream per component, or runs a model. The counter is shared across the paths listed, so two SBOM scans and an AI assessment inside ten seconds from one address is one request too many. The same figure is published by GET /api/v1/sbom/formats under limits.perEndpoint, and a test binds that advertisement to the rule.
A request the edge rule blocks answers 429 from Cloudflare itself, ahead of the API, so its body is not the JSON envelope below. Plan on the status code.
Every other route runs without an enforced per-address budget. Cached answers never reach the counter, so repeating a request that answered X-Cache: HIT costs nothing.
The headers
Responses under /api/ carry three headers:
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 26
X-RateLimit-Reset: 1790557390They come from a counter inside the API worker instance that served the request. That instance is one of many, each with its own counter, so the figures describe a burst that landed on one instance and are not an account-wide budget. Treat them as advisory: a falling Remaining means you are sending quickly, and reaching zero on one instance answers 429 from that instance with Retry-After set, but a Remaining of 26 is not a promise of 26 more requests. The Limit shown is the per-instance figure for the route's bucket: 30 a minute for most routes, 60 for /issues, 15 for /search and /trending, 10 for POST /cve/status, 20 for /prefetch/* and 500 for the vendor-hash chunk operations.
Repeated requests for paths that do not exist are counted the same way, and an instance that sees more than ten in a minute from one address answers 403 with no body to the rest.
Rate Limit Exceeded
A 429 from the API itself looks like this:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Cache-Control: no-store
{
"error": "Too many requests",
"retryAfter": 60
}Honour Retry-After. A 429 from the edge rule carries Cloudflare's own body; wait ten seconds before the next call to the paths it covers.
Best Practices
Implement Exponential Backoff
async function fetchWithRetry(url, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const response = await fetch(url);
if (response.status === 429) {
const retryAfter = Number(response.headers.get('Retry-After')) || 10;
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000 * (i + 1)));
continue;
}
return response;
}
throw new Error('Max retries exceeded');
}Cache Responses
Most vulnerability data changes infrequently. Cache responses locally:
- Stats: an hour is plenty; the server refreshes every two
- Issues: an hour
- OSV/Vulnerabilities: 15 minutes
- Repology: two hours, matching the server
Batch Requests
Prefer the routes that answer for many identifiers in one call over a loop of single lookups: POST /api/v1/cve/batch for up to 50 CVEs, POST /api/v1/cve/status for status badges, GET /api/v1/packages/index for membership checks. Where a loop is unavoidable, space it:
for pkg in openssl curl nginx; do
curl "https://api.vulnpatch.dev/api/v1/osv/$pkg"
sleep 1
doneCaching
The API caches server-side with these TTLs:
| Endpoint | Cache TTL |
|---|---|
/stats | 2 hours |
/issues | 1 hour |
/nix/cves, /nix/channels | 1 hour |
/nix/repology/:package | 2 hours |
/nix/hydra/:package | 4 hours |
/ecosystem-stats | 24 hours |
/recent-advisories | 10 minutes |
/search | 30 minutes |
/cve/:id | 2 hours |
Cache status is indicated in the X-Cache header:
X-Cache: HIT- Response served from cacheX-Cache: MISS- Fresh response generated
Most GET answers also carry a short Cache-Control: public, max-age so an intermediary can hold them; errors and degraded answers are no-store.