Vulnix
API

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.dev

There 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 ULID01M2NMSFVBHZTYR842YNP45TF3. 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: 60

x-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.

On this page