Conventions
Base URL, authorization model, object shapes, errors, and rate limits — everything that is true of every Vulnix API endpoint.
Everything on this page holds for every endpoint in the reference. Read it once and the per-resource pages become a list of paths and fields.
Base URL
https://api.vulnix.devThere is no version segment in the path. Treat responses as open maps: new fields are added over time, so parse the ones you need and ignore the rest rather than failing on an unknown key.
Authorization
Every request needs a token in an Authorization header. See
Authentication for how to create one.
curl https://api.vulnix.dev/runs \
-H "Authorization: Bearer vnx_your_token_here"Two gates, not one
A request has to pass both checks, and they are independent:
Prop
Type
This catches people out, so it is worth stating plainly: ticking a scope does not grant
access on its own. A token created by a viewer carrying runs:read still cannot call
GET /runs, because that endpoint requires admin. The scope narrows the creator's role; it
never widens it.
Roles rank owner > admin > analyst > viewer. Each endpoint in the reference lists the
minimum role it needs alongside its scope. Broadly:
Prop
Type
Endpoints no token can reach
Authorization is deny-by-default: a token may call an endpoint only if that endpoint declares
a scope. Everything else — session management, token management, anything that moves money —
returns 403 api_token_route_not_allowed no matter which scopes the token carries. There is no
scope that unlocks them. See
What tokens cannot do.
Requests
Send Content-Type: application/json on any request with a body. Two endpoints take a file
upload as multipart/form-data instead — uploading a scope document and a scope API spec —
and both are marked as such in the reference.
PATCH bodies are sparse: send only the fields you are changing. A field you omit is left
alone; a field you set to null is cleared where the field is nullable.
Responses
There is no envelope around successful responses. A single object is returned at the top level:
{ "id": "01M2NMSFVBHZTYR842YNP45TF3", "status": "draft", "org_id": "01K5ADM…" }A collection comes back under a key named for the resource, leaving room for summary data alongside it:
{
"findings": [],
"severity_breakdown": { "critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0 }
}Deletes and other actions with nothing to return answer with a status marker:
{ "status": "deleted" }Identifiers
Every id is a 26-character ULID — 01M2NMSFVBHZTYR842YNP45TF3.
They sort lexicographically by creation time, so ordering a list of ids as strings orders them
chronologically. Treat them as opaque strings; do not parse them.
Timestamps
ISO 8601 with a UTC offset — 2026-09-16T17:20:04.708773+00:00. Fields that can be unset
(completed_at, last_tested_at) are null rather than absent.
Collections
List endpoints return the whole collection in one response — there is no pagination, no
cursor, and no limit parameter. For an organization with years of history that response can
be large, so fetch lists on a schedule rather than in a request path where latency matters.
Filtering exists only where the reference documents a query parameter for it, GET /findings
being the main one. Everything else filters client-side.
Errors
Every failure uses one envelope, whatever produced it:
{
"error": {
"code": "api_token_scope_missing",
"message": "this API token does not carry the 'findings:read' scope",
"details": { "required_scope": "findings:read", "granted_scopes": ["runs:read"] }
},
"request_id": "01M2NMVMHGFJF864KD2GP7TP51"
}Branch on code, never on message — messages are written for humans and get reworded.
details is shaped per code and is {} when there is nothing useful to add.
request_id identifies the exact request in our logs. Log it, and quote it when contacting
support.
Validation failures
A malformed body returns 422 with code: "invalid_request" and a fields array pointing at
each offending value:
{
"error": {
"code": "invalid_request",
"message": "The request contains invalid values.",
"details": {
"fields": [
{
"location": ["body", "idempotency_key"],
"message": "String should have at least 8 characters",
"type": "string_too_short"
}
]
}
},
"request_id": "01M2NMTA1P76163FFX64PTY3MT"
}Authorization and rate-limit codes
Prop
Type
Common per-resource codes
Prop
Type
Rate limits
Two budgets, counted separately per token:
Prop
Type
Because they are separate, polling a run's status can never exhaust your ability to start one.
Every response carries the current state of the budget it was counted against:
x-ratelimit-limit: 120
x-ratelimit-remaining: 119
x-ratelimit-reset: 60x-ratelimit-reset is seconds until the window rolls over. Exceeding a budget returns 429
with Retry-After carrying the same number:
{
"error": {
"code": "rate_limited",
"message": "too many requests - slow down and retry after the window resets",
"details": { "retry_after_seconds": 27 }
},
"request_id": "01M2NMWN47R1WQ0793T3NJ1DAT"
}Retry on Retry-After, not on a fixed backoff — the header is exact, so sleeping for it wastes
no time and guarantees the next request lands in a fresh window.
Withheld detail on the free trial
On a free trial, findings rated critical or high come back with a reduced shape: id,
run_id, domain_id, severity, status, created_at, and locked: true. The title,
description, CVSS, proof of concept, and remediation steps are omitted rather than blanked.
Every finding object carries locked, so the check is if (finding.locked) rather than
inspecting the plan. Agent-trace nodes follow the same rule: structure stays, the transcript is
withheld. Report and timeline exports refuse outright with 403 premium_detail_required when
the run contains a locked finding, because a rendered file cannot be redacted field-by-field.
On a paid plan locked is always false and nothing is withheld.
Live event streams
Three endpoints stream Server-Sent Events
instead of returning JSON: a run's events, a PR review's events, and nothing else. They respond
with Content-Type: text/event-stream and stay open.
Each event carries a Redis stream id as its SSE id. Send the last one you saw back as
Last-Event-ID to resume without gaps — a browser EventSource does this automatically, and a
server-side consumer should do it explicitly on reconnect. Events live for 7 days, after which
the stream replays as empty rather than erroring.