> ## Documentation Index
> Fetch the complete documentation index at: https://docs.perfai.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Reference

> Complete reference for the Perfai integration endpoints — register and list apps, trigger tests and vision runs, poll run status, and retrieve vulnerabilities.

All endpoints share one base URL — `https://api.perfai.ai/v1` on the cloud platform (on-premise, swap in your own host). Every request needs a bearer token on the `Authorization` header.

**Machine-readable spec.** The full OpenAPI 3.1.1 contract is served at [`https://api.perfai.ai/v1/openapi.yaml`](https://api.perfai.ai/v1/openapi.yaml) — import it into Postman, an SDK generator, or any API tooling.

New here? Start with the [Quickstart](/docs/api/overview#quickstart) — it shows how to get a token and run your first test end to end. The [API overview](/docs/api/overview) also covers authentication, rate limits, the run state machines, and a complete integration example. The examples below use the cloud host `api.perfai.ai`; replace it with your own host if you run Perfai on-premise.

***

## Errors

Every error response uses one [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) problem+json shape (`Content-Type: application/problem+json`) — the endpoints' own errors and framework errors (auth, validation) alike:

```json theme={null}
{
  "type": "https://perfai.ai/errors/app-already-registered",
  "title": "An app with this URL is already registered",
  "status": 409,
  "appId": "683abc1234def5678901abcd"
}
```

* `type` — a stable URI identifying the error kind; branch on this, not the human-readable `title`.
* `title` / `detail` — a short summary and, when available, specifics.
* `status` — mirrors the HTTP status code.
* Some errors add fields: a `402` carries `current_balance` and `action_type`; a validation `400` carries an `errors` array of per-field messages.

The **Status codes** table under each endpoint lists the codes it can return.

***

## Register app

Registers a target app and drives it to a testable state. On the default auto sign-up path, Perfai runs **one initial security test** as the final onboarding step, so first findings are available as soon as `registrationStatus` is `COMPLETED`. Supplying your own `tenantOneCredentials` or `createTestAccounts: false` **skips** that initial test. Either way, run further tests on demand with [Run Security Agent](#run-security-agent).

Perfai onboards the app for you — **entirely from your inputs, with sensible defaults**:

* **URL only — the default.** Perfai runs the **full autonomous onboarding**: it maps your API surface, **creates test accounts for you** (auto sign-up), validates the logins, discovers roles, provisions role-based (RBAC) accounts, and completes registration. This is what lets a URL-only registration run authenticated, RBAC and cross-tenant (BOLA) tests — not just a shallow unauthenticated test.
* **You provide credentials** (`tenantOneCredentials` / `tenantTwoCredentials`) — Perfai maps your API surface and uses your accounts instead of signing up, then runs role/RBAC mapping.
* **`createTestAccounts: false`** — Perfai maps your API surface by exploring the app but does **not** create accounts: an unauthenticated (public-endpoint-only) test.

Either way, registration completes on its own. **Poll [Get app](#get-app) until `registrationStatus` is `COMPLETED`, then trigger a test.**

<Note>
  Registration time depends heavily on your app — its size, structure, number of roles, sign-up flow, and how quickly it responds — so there's no fixed duration. It ranges from a few minutes to considerably longer, and the **auto-signup path is the slowest** (account creation, login validation, role discovery, RBAC provisioning, and full app mapping all run first). **Poll [Get app](#get-app) until `registrationStatus` is `COMPLETED` (or `FAILED`/`CANCELLED`) rather than assuming a fixed time** — use a generous timeout, and don't treat a still-`IN_PROGRESS` app as stalled just because it's taking a while.
</Note>

<Note>
  **If Perfai can't sign up** — no public sign-up page, a CAPTCHA, an SSO wall, or an unreachable URL — it **pauses and emails you** to add credentials, rather than silently continuing unauthenticated. The app stays `IN_PROGRESS`; re-register with `tenantOneCredentials` to continue.
</Note>

```http theme={null}
POST /v1/apps
```

<ParamField header="Authorization" type="string" required>
  `Bearer <token>`.
</ParamField>

<ParamField body="appUrl" type="string" required>
  Base URL of the application under test (`http`/`https`). Example: `https://api.payments.example.com`
</ParamField>

<ParamField body="label" type="string" default="host of appUrl">
  App name (max 150 chars). Defaults to the host of `appUrl` (e.g. `api.payments.example.com`) when omitted.
</ParamField>

<ParamField body="tenantOneCredentials" type="RoleCredential[]">
  Credentials for the primary tenant, one entry per role (max 20). Omit to have Perfai auto-create accounts (default), or for unauthenticated APIs (with `createTestAccounts: false`).

  <Expandable title="RoleCredential">
    <ParamField body="role" type="string" required>
      Role this login represents — `"Admin"`, `"User"`, or a custom role name. Drives role-based access (RBAC/BOLA) testing.
    </ParamField>

    <ParamField body="username" type="string" required>
      Username or email used to log in.
    </ParamField>

    <ParamField body="password" type="string" required>
      Password. Encrypted at rest; never logged or returned.
    </ParamField>

    <ParamField body="loginUrl" type="string" default="appUrl">
      URL where the credentials log in.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="tenantTwoCredentials" type="RoleCredential[]">
  Credentials for a second tenant, used for cross-tenant (BOLA) testing (max 20). Same `RoleCredential` shape as `tenantOneCredentials`.
</ParamField>

<ParamField body="createTestAccounts" type="boolean" default="true">
  Whether Perfai autonomously creates test accounts (auto sign-up) when no credentials are supplied. Only applies when no credentials are supplied. Set `false` to register for an unauthenticated (public-endpoint-only) test — no accounts, so authenticated/RBAC/BOLA tests won't run.
</ParamField>

<ParamField body="skipEndpoints" type="string[]">
  Endpoints to exclude from **every** test of this app, as `method:path` entries (max 200). Use it to keep destructive operations out of scope — see [Excluding endpoints](#excluding-endpoints-from-tests).
</ParamField>

<ParamField body="headers" type="string[]">
  Extra HTTP headers sent to your app on every request, as `Name: value` entries (max 50). Use it for a gateway API key, a tenant selector, or a feature flag your app requires.
</ParamField>

<ParamField body="maxSteps" type="integer" default="150">
  How many exploration steps Perfai may take while mapping your API surface (1–1000). Defaults to your organization's configured value, which itself defaults to 150.
</ParamField>

<Note>
  **Terms used here.** A **tenant** is a customer or account boundary inside *your* application (for example two separate customer accounts). Supplying a second tenant's logins in `tenantTwoCredentials` lets Perfai test whether one tenant can reach another tenant's data — **BOLA** (Broken Object Level Authorization). A **role** is the permission level a login has (e.g. `Admin` vs `User`); giving several roles drives role-based access control (**RBAC**) testing.
</Note>

<Note>
  Credentials are optional — unauthenticated or public APIs register and test without them. When you supply them, Perfai logs in as each role to run authenticated and role-based access testing. Passwords are sent over TLS, stored encrypted, and never logged or returned. The API style (REST or GraphQL) is detected automatically — you don't declare it.
</Note>

### Excluding endpoints from tests

`skipEndpoints` keeps chosen endpoints out of **every** test of the app. Reach for it before you point Perfai at anything you can't freely mutate — account deletion, logout, payment capture, bulk imports.

Each entry is `method:path`:

```json theme={null}
"skipEndpoints": [
  "DELETE:/api/v1/users/{id}",
  "POST:/api/v1/payments/capture",
  "*:/internal/**"
]
```

* **Method** is an HTTP verb, or `*` for any.
* **Path** may use `**` as a glob — `*:/internal/**` excludes everything under `/internal`.

<Warning>
  Use exactly one colon per entry — `DELETE:/api/v1/users/{id}`, not `DELETE /api/v1/users/{id}`. A malformed entry is rejected when you register, which is deliberate: an unvalidated filter would otherwise fail the test itself rather than quietly skip nothing.
</Warning>

### Sending extra headers

`headers` adds HTTP headers to every request Perfai makes against your app — on top of anything the credentials supply. Use it when your app needs a gateway key, a tenant selector, or a feature flag to answer at all:

```json theme={null}
"headers": [
  "X-Api-Key: 9f8c1e02-5b6a-4b1e",
  "X-Tenant-Id: acme"
]
```

Each entry is `Name: value`. Values are sent as supplied and are never returned by the API.

### Controlling mapping depth

Perfai explores your app to map its API surface. `maxSteps` caps that exploration:

```json theme={null}
"maxSteps": 300
```

Raise it when the mapping is missing endpoints — large apps, deep navigation, or many nested workflows need more steps. Higher values map more thoroughly but lengthen registration. Omit it to use your organization's configured value (**150** unless you've changed it).

Only `appUrl` is required — the **Minimal** form runs full autonomous onboarding, defaulting the label to the host and creating test accounts for you. The **Full** form shows every field.

<RequestExample>
  ```bash Full theme={null}
  curl -s -X POST \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "appUrl": "https://api.payments.example.com",
      "label": "Payments API",
      "tenantOneCredentials": [
        {
          "role": "Admin",
          "username": "admin@example.com",
          "password": "••••••••",
          "loginUrl": "https://api.payments.example.com/login"
        },
        { "role": "User", "username": "user@example.com", "password": "••••••••" }
      ],
      "tenantTwoCredentials": [
        { "role": "Admin", "username": "t2-admin@example.com", "password": "••••••••" }
      ],
      "skipEndpoints": [
        "DELETE:/api/v1/users/{id}",
        "POST:/api/v1/payments/capture",
        "*:/internal/**"
      ],
      "headers": [
        "X-Api-Key: 9f8c1e02-5b6a-4b1e",
        "X-Tenant-Id: acme"
      ],
      "maxSteps": 300
    }' \
    "https://api.perfai.ai/v1/apps"
  ```

  ```bash Minimal theme={null}
  curl -s -X POST \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"appUrl": "https://api.payments.example.com"}' \
    "https://api.perfai.ai/v1/apps"
  ```
</RequestExample>

The `Location` header contains the app URL to poll. Every `201` also returns a top-level `taskId` of the form `task_reg_<appId>` — poll it via [Get task status](#get-task-status) for the same registration progress. The onboarding handle in the body depends on the path taken:

* **Default (auto-signup)** — an `onboarding` handle (`mode: "auto-signup"` plus a message).
* **`createTestAccounts: false`** — a `specExtraction` handle instead: no accounts are created; Perfai maps your API surface from a spec-extraction vision run, pollable via its `statusUrl`.
* **Re-registering a dead app** — a `replaced` handle alongside the usual fields.

<ResponseExample>
  ```json Default (auto-signup) theme={null}
  {
    "appId": "683abc1234def5678901abcd",
    "taskId": "task_reg_683abc1234def5678901abcd",
    "registrationStatus": "IN_PROGRESS",
    "createdAt": "2026-07-04T09:00:00.000Z",
    "statusUrl": "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd",
    "onboarding": {
      "mode": "auto-signup",
      "message": "Autonomous onboarding started (account creation, role discovery, RBAC and mapping). Poll GET /apps/{appId} until registrationStatus is COMPLETED, then trigger a security test."
    }
  }
  ```

  ```json createTestAccounts: false theme={null}
  {
    "appId": "683abc1234def5678901abcd",
    "taskId": "task_reg_683abc1234def5678901abcd",
    "registrationStatus": "IN_PROGRESS",
    "createdAt": "2026-07-04T09:00:00.000Z",
    "statusUrl": "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd",
    "specExtraction": {
      "visionRunId": "683def5678901abcd1234abc",
      "state": "QUEUED",
      "statusUrl": "https://api.perfai.ai/v1/tasks/task_vr_683def5678901abcd1234abc"
    }
  }
  ```

  ```json Re-registering (replaced) theme={null}
  {
    "appId": "683abc1234def5678901abcd",
    "taskId": "task_reg_683abc1234def5678901abcd",
    "registrationStatus": "IN_PROGRESS",
    "createdAt": "2026-07-04T09:00:00.000Z",
    "statusUrl": "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd",
    "replaced": { "previousAppId": "671fed9876cba5432109fedc", "previousStatus": "FAILED" }
  }
  ```
</ResponseExample>

### Response

<ResponseField name="appId" type="string">The registered app's identifier.</ResponseField>
<ResponseField name="taskId" type="string">Registration task of the form `task_reg_<appId>` — poll it via [Get task status](#get-task-status).</ResponseField>
<ResponseField name="registrationStatus" type="string">Always `IN_PROGRESS` immediately after registering.</ResponseField>
<ResponseField name="createdAt" type="ISO 8601 string">When the app was registered.</ResponseField>
<ResponseField name="statusUrl" type="string">Absolute [Get app](#get-app) URL — poll it until `registrationStatus` is `COMPLETED`.</ResponseField>

<ResponseField name="onboarding" type="object">
  Present on the default (URL-only) path. Carries `mode: "auto-signup"` and a human-readable `message`.
</ResponseField>

<ResponseField name="specExtraction" type="object">
  Present instead of `onboarding` when `createTestAccounts: false`. Carries `visionRunId`, `state`, and a pollable [Get task status](#get-task-status) `statusUrl` for the mapping run.
</ResponseField>

<ResponseField name="replaced" type="object">
  Present only when re-registering a URL whose previous API registration had failed or been abandoned. Carries `previousAppId` and `previousStatus`.
</ResponseField>

**Re-registering a URL.** If a URL's previous registration **through this API** failed or was abandoned, re-registering it **replaces** that dead app automatically — no manual cleanup. A **usable** app for that URL — one that's `COMPLETED` or still onboarding — is never replaced; you get `409` with its existing `appId` to reuse instead (see [Status codes](#status-codes)). An app first registered **outside this API** is also never touched — you get `409 app-registered-outside-api`; remove that app, then retry. In every case the signal that the app is ready to test is `registrationStatus: COMPLETED` from [Get app](#get-app) — not the `onboarding` handle alone.

### Status codes

| Code  | Meaning                                                                                                                                                                                                                                     |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `201` | App registered (or idempotent replay of an existing registration)                                                                                                                                                                           |
| `400` | Validation error                                                                                                                                                                                                                            |
| `402` | Organization credit balance exhausted                                                                                                                                                                                                       |
| `403` | Your role cannot register apps                                                                                                                                                                                                              |
| `409` | The URL already has a **usable** app — `app-already-registered`, with the existing `appId` to reuse; **or** it has an app created outside this API that can't be auto-replaced — `app-registered-outside-api` (remove that app, then retry) |
| `429` | Rate limit exceeded (5 requests / 60 s)                                                                                                                                                                                                     |
| `503` | Automatic sign-up is unavailable on this deployment — register with `tenantOneCredentials` instead                                                                                                                                          |

***

## Get app

Returns a single app, including its registration state. Use it to poll onboarding progress after registering — when `registrationStatus` is `COMPLETED`, the app is fully onboarded and ready to test.

```http theme={null}
GET /v1/apps/:appId
```

<ParamField path="appId" type="string" required>
  Identifier of the app to fetch.
</ParamField>

<ParamField header="Authorization" type="string" required>
  `Bearer <token>`.
</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "appId": "683abc1234def5678901abcd",
    "label": "Payments API",
    "appUrl": "https://api.payments.example.com",
    "basePath": "/v2",
    "registrationStatus": "COMPLETED",
    "createdAt": "2026-07-04T09:00:00.000Z",
    "scheduleType": "nightly"
  }
  ```
</ResponseExample>

### Response

<ResponseField name="appId" type="string">App identifier.</ResponseField>
<ResponseField name="label" type="string">Human-readable app name.</ResponseField>
<ResponseField name="appUrl" type="string">Base URL of the app under test.</ResponseField>
<ResponseField name="basePath" type="string">API base path (e.g. `/api/v2`).</ResponseField>

<ResponseField name="registrationStatus" type="string">
  Registration state. Moves `PENDING → IN_PROGRESS → COMPLETED | FAILED | CANCELLED`; the last three are terminal — stop polling once you reach one.
</ResponseField>

<ResponseField name="createdAt" type="ISO 8601 string">When the app was registered.</ResponseField>

<ResponseField name="scheduleType" type="string">
  How often Perfai re-tests this app on its own. Apps inherit your organization's setting when they are registered and fall back to `nightly`, so an app may be on a schedule you did not ask for here — **each run uses credits**. Change it in the dashboard under **Edit app**.
</ResponseField>

### Status codes

| Code  | Meaning                                        |
| ----- | ---------------------------------------------- |
| `200` | App found                                      |
| `401` | Missing or expired JWT                         |
| `404` | App not found or belongs to a different tenant |

***

## Run Account Setup

Supplies test-account credentials for an app that cannot sign itself up.

Perfai creates its own test accounts wherever it can. Some applications make that impossible — no public sign-up page, a CAPTCHA, an SSO wall, or invite-only registration. In those cases onboarding **pauses and waits for you** rather than quietly falling back to an unauthenticated test, which would report far less than a real one. Submit your credentials here and onboarding resumes from where it stopped.

This takes the same `tenantOneCredentials` / `tenantTwoCredentials` shape as [Register app](#register-app), so if you already have credentials you can supply them either at registration or here afterwards.

```http theme={null}
POST /v1/apps/:appId/account-setup
```

<ParamField path="appId" type="string" required>
  The app awaiting credentials.
</ParamField>

<ParamField body="tenantOneCredentials" type="RoleCredential[]" required>
  Accounts for your primary tenant, one per role (max 20). At least one is required — without a working account Perfai cannot test anything behind your login.
</ParamField>

<ParamField body="tenantTwoCredentials" type="RoleCredential[]">
  Accounts for a second tenant (max 20). Supply these to enable cross-tenant (BOLA) testing — checks that one customer cannot reach another customer's data. Without a second tenant those checks cannot run.
</ParamField>

Each entry has the same `RoleCredential` fields as at registration: `role`, `username`, `password`, and an optional `loginUrl`.

<RequestExample>
  ```bash cURL theme={null}
  curl -s -X POST \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "tenantOneCredentials": [
        { "role": "Admin", "username": "perfai-admin@example.com", "password": "..." }
      ],
      "tenantTwoCredentials": [
        { "role": "Admin", "username": "perfai-admin-b@example.com", "password": "..." }
      ]
    }' \
    "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd/account-setup"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "appId": "683abc1234def5678901abcd",
    "taskId": "task_reg_683abc1234def5678901abcd",
    "accountsSaved": 2,
    "onboardingResumed": true,
    "statusUrl": "https://api.perfai.ai/v1/tasks/task_reg_683abc1234def5678901abcd"
  }
  ```
</ResponseExample>

### Response

<ResponseField name="appId" type="string">The app the accounts were saved against.</ResponseField>
<ResponseField name="taskId" type="string">Registration task — poll [Get task status](#get-task-status) to watch onboarding continue.</ResponseField>
<ResponseField name="accountsSaved" type="integer">How many credential sets were stored.</ResponseField>

<ResponseField name="onboardingResumed" type="boolean">
  `true` when onboarding was waiting for these credentials and has now resumed. `false` means they are saved but nothing was waiting — either onboarding had not reached that point yet, or it stopped for another reason.
</ResponseField>

<ResponseField name="statusUrl" type="string">Absolute URL to poll.</ResponseField>

Retrying the same request will not create a second set of real accounts in your application.

### Status codes

| Code  | Meaning                                                                                       |
| ----- | --------------------------------------------------------------------------------------------- |
| `200` | Credentials stored                                                                            |
| `400` | Validation error — check the credential fields                                                |
| `401` | Missing or expired JWT                                                                        |
| `403` | Your role cannot supply test accounts                                                         |
| `404` | App not found, or belongs to a different tenant                                               |
| `409` | The app was created in the console, not through this API — add its credentials in the console |

***

## Delete app

Permanently deletes an app you registered through the API, along with all of its data — test runs, findings, mappings, and stored credentials. This can't be undone.

```http theme={null}
DELETE /v1/apps/:appId
```

You can only delete apps you registered through the API. An app created in the dashboard returns `409` — delete it from the dashboard instead.

<ParamField path="appId" type="string" required>
  The app to delete. Must be one you registered through the API.
</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s -X DELETE \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "appId": "683abc1234def5678901abcd",
    "status": "DELETED",
    "previousRegistrationStatus": "COMPLETED"
  }
  ```
</ResponseExample>

### Response

<ResponseField name="appId" type="string">The deleted app's identifier.</ResponseField>
<ResponseField name="status" type="string">Always `DELETED` on success.</ResponseField>
<ResponseField name="previousRegistrationStatus" type="string">The app's registration state at the moment it was deleted.</ResponseField>

### Status codes

| Code  | Meaning                                                             |
| ----- | ------------------------------------------------------------------- |
| `200` | App and all of its data deleted                                     |
| `401` | Missing or expired JWT                                              |
| `403` | Your role can't delete apps (Viewer is read-only)                   |
| `404` | App not found or belongs to a different tenant                      |
| `409` | App was created in the dashboard, not via the API — delete it there |

***

## List apps

Returns all apps registered to your organization, sorted by creation date (newest first). Use this endpoint to resolve the `appId` for the service you want to test.

```http theme={null}
GET /v1/apps
```

<ParamField query="page" type="integer" default="1">Page number.</ParamField>
<ParamField query="pageSize" type="integer" default="20">Items per page, max 100.</ParamField>
<ParamField query="search" type="string">Case-insensitive filter on app label or API name.</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/apps?page=1&pageSize=20&search=Payments"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "data": [
      {
        "appId": "683abc1234def5678901abcd",
        "label": "Payments API",
        "appUrl": "https://api.payments.example.com",
        "basePath": "/v2",
        "registrationStatus": "COMPLETED",
        "createdAt": "2025-10-14T08:22:31.000Z",
        "scheduleType": "nightly"
      }
    ],
    "total": 42,
    "page": 1,
    "pageSize": 20
  }
  ```
</ResponseExample>

### Response

<ResponseField name="data" type="App[]">
  Paginated apps — newest first.

  <Expandable title="App">
    <ResponseField name="appId" type="string">App identifier — use this in all other endpoints.</ResponseField>
    <ResponseField name="label" type="string">Human-readable app name.</ResponseField>
    <ResponseField name="appUrl" type="string">Base URL of the API under test.</ResponseField>
    <ResponseField name="basePath" type="string">API base path (e.g. `/api/v2`).</ResponseField>
    <ResponseField name="registrationStatus" type="string">Registration state (`PENDING`, `IN_PROGRESS`, `COMPLETED`, `FAILED`, `CANCELLED`).</ResponseField>
    <ResponseField name="createdAt" type="ISO 8601 string">When the app was registered.</ResponseField>
    <ResponseField name="scheduleType" type="string">How often Perfai re-tests this app on its own — `none`, `nightly`, `custom`, `weekly_saturday`, `biweekly_saturday`, `monthly_first`. Apps inherit your organization's setting and fall back to `nightly`, so an app may be on a schedule you did not ask for here — **each run uses credits**. Change it in the dashboard under Edit app.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="total" type="integer">Total apps across all pages.</ResponseField>
<ResponseField name="page" type="integer">Current page.</ResponseField>
<ResponseField name="pageSize" type="integer">Page size used for this response.</ResponseField>

### Status codes

| Code  | Meaning                |
| ----- | ---------------------- |
| `200` | Success                |
| `401` | Missing or expired JWT |

***

## Run Vision Agent

Launches a vision run — Perfai's browser automation that explores your application like a real user to map the API surface, validate credentials, and discover roles. Vision runs automatically during onboarding; use this endpoint to re-run it on demand — for example after a release that changes your UI or API surface.

Vision runs triggered through the API always execute browserless in the background. Credentials are resolved server-side from the app's stored authentication — nothing travels in the request.

<Note>
  **When to use this.** Vision runs on their own during onboarding and before each scheduled test, so it is **not** a required step in the basic register → test → results flow. Call this endpoint only to re-map on demand after your app's UI or API changes, or to re-validate stored credentials.
</Note>

```http theme={null}
POST /v1/apps/:appId/vision-agent
```

<ParamField path="appId" type="string" required>App identifier returned by `GET /apps`.</ParamField>

<ParamField header="Authorization" type="string" required>`Bearer <token>`.</ParamField>

<ParamField body="operation" type="enum" default="map">
  `map` — explore the app and map or refresh your API surface. `validate-login` — verify stored credentials still work. `discover-roles` — discover RBAC roles. `verify-rbac` — re-verify role capabilities.
</ParamField>

The body is optional — `map` is the default. Pass `-d '{"operation":"validate-login"}'` (with `-H "Content-Type: application/json"`) to run a different operation.

<RequestExample>
  ```bash cURL theme={null}
  curl -s -X POST \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd/vision-agent"
  ```
</RequestExample>

<ResponseExample>
  ```json 202 Accepted theme={null}
  {
    "taskId": "task_vr_683def5678901abcd1234abc",
    "appId": "683abc1234def5678901abcd",
    "operation": "map",
    "state": "QUEUED",
    "createdAt": "2026-07-04T09:05:00.000Z",
    "updatedAt": null,
    "statusUrl": "https://api.perfai.ai/v1/tasks/task_vr_683def5678901abcd1234abc"
  }
  ```
</ResponseExample>

The `Location` header contains the status URL to poll.

### Response

<ResponseField name="taskId" type="string">Task handle — poll it via [Get task status](#get-task-status).</ResponseField>
<ResponseField name="appId" type="string">App the run belongs to.</ResponseField>
<ResponseField name="operation" type="string">The operation that was triggered.</ResponseField>
<ResponseField name="state" type="string">Always `QUEUED` immediately after triggering.</ResponseField>
<ResponseField name="createdAt" type="ISO 8601 string">When the run was created.</ResponseField>
<ResponseField name="updatedAt" type="ISO 8601 string | null">When the state last changed.</ResponseField>
<ResponseField name="statusUrl" type="string">Absolute URL to poll for status.</ResponseField>

### Status codes

| Code  | Meaning                                                                             |
| ----- | ----------------------------------------------------------------------------------- |
| `202` | Vision run accepted (or idempotent replay)                                          |
| `400` | Invalid request body                                                                |
| `402` | Organization credit balance exhausted                                               |
| `403` | Your role cannot trigger vision runs                                                |
| `404` | App not found or belongs to a different tenant                                      |
| `409` | A vision run is already active for this app (response includes `activeVisionRunId`) |
| `429` | Rate limit exceeded (5 requests / 60 s)                                             |
| `500` | The run could not be created — retry safely                                         |

***

## Run Security Agent

Schedules an asynchronous security test for the specified app. The app must be onboarded first — its `registrationStatus` (from [Get app](#get-app)) must be `COMPLETED`.

```http theme={null}
POST /v1/apps/:appId/security-agent
```

<ParamField path="appId" type="string" required>
  App identifier returned by `GET /apps`.
</ParamField>

<ParamField header="Authorization" type="string" required>
  `Bearer <token>`.
</ParamField>

The security test covers all available categories for the app — there is no request body.

<RequestExample>
  ```bash cURL theme={null}
  curl -s -X POST \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd/security-agent"
  ```
</RequestExample>

<ResponseExample>
  ```json 202 Accepted theme={null}
  {
    "taskId": "task_wf_683def5678901abcd1234abc",
    "appId": "683abc1234def5678901abcd",
    "state": "QUEUED",
    "createdAt": "2025-10-14T09:00:00.000Z",
    "updatedAt": null,
    "statusUrl": "https://api.perfai.ai/v1/tasks/task_wf_683def5678901abcd1234abc"
  }
  ```
</ResponseExample>

The `Location` response header also contains the `statusUrl` value. Use either to poll for run completion.

### Response

<ResponseField name="taskId" type="string">Task handle — poll it via [Get task status](#get-task-status).</ResponseField>
<ResponseField name="appId" type="string">App the run belongs to.</ResponseField>
<ResponseField name="state" type="SecurityRunState">Always `QUEUED` immediately after triggering.</ResponseField>
<ResponseField name="createdAt" type="ISO 8601 string">When the run was created.</ResponseField>
<ResponseField name="updatedAt" type="ISO 8601 string | null">When the state last changed.</ResponseField>
<ResponseField name="statusUrl" type="string">Absolute URL to poll for status.</ResponseField>

### Status codes

| Code  | Meaning                                                                                       |
| ----- | --------------------------------------------------------------------------------------------- |
| `202` | Run scheduled (or idempotent replay of an already-scheduled run)                              |
| `400` | Invalid request body, or the app is not yet registered (`registrationStatus` not `COMPLETED`) |
| `401` | Missing or expired JWT                                                                        |
| `402` | Your organization is out of credits — a security test consumes credits                        |
| `403` | Caller's role cannot trigger tests                                                            |
| `404` | App not found or belongs to a different tenant                                                |
| `409` | A run is already active for this app                                                          |
| `429` | Rate limit exceeded (10 req / 60 s per IP)                                                    |

***

## Get task status

Returns the current state of any task on this API — a registration, a security test, or a Vision Agent run. Every action that starts work returns a `taskId`; poll this endpoint until `state` is terminal.

```http theme={null}
GET /v1/tasks/:taskId
```

<ParamField path="taskId" type="string" required>
  Task identifier returned by register, run security test, or run Vision Agent. Treat it as opaque — pass it back exactly as given.
</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/tasks/task_wf_683def5678901abcd1234abc"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "taskId": "task_wf_683def5678901abcd1234abc",
    "type": "security-agent",
    "appId": "683abc1234def5678901abcd",
    "state": "COMPLETED",
    "createdAt": "2026-07-04T09:00:00.000Z",
    "updatedAt": "2026-07-04T09:41:22.000Z",
    "result": {
      "runNumber": 7,
      "vulnerabilitiesUrl": "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd/vulnerabilities"
    }
  }
  ```
</ResponseExample>

### Response

<ResponseField name="taskId" type="string">Echoes the requested id.</ResponseField>
<ResponseField name="type" type="string">`security-agent`, `vision-agent`, or `registration`.</ResponseField>
<ResponseField name="appId" type="string">App the task belongs to.</ResponseField>
<ResponseField name="state" type="TaskState">Current state — see [Test state machine](/docs/api/overview#test-state-machine).</ResponseField>
<ResponseField name="createdAt" type="ISO 8601 string">When the task was created.</ResponseField>
<ResponseField name="updatedAt" type="ISO 8601 string | null">When it last changed; `null` if unchanged since creation.</ResponseField>

<ResponseField name="result" type="object">
  Type-specific detail:

  * **`security-agent`** — `runNumber`, plus `vulnerabilitiesUrl` once `state` is `COMPLETED`. A `FAILED` or `INTERRUPTED` test has no trustworthy findings, so no link is offered.
  * **`vision-agent`** — `operation`: `map`, `validate-login`, `discover-roles`, or `verify-rbac`.
  * **`registration`** — `registrationStatus`: `PENDING`, `IN_PROGRESS`, `COMPLETED`, `FAILED`, or `CANCELLED`.
</ResponseField>

If a registration task reports `state: AWAITING_INPUT`, Perfai could not create test accounts on its own and is waiting for yours — send them to [Run Account Setup](#run-account-setup). The task returns to `RUNNING` once they are accepted.

### Status codes

| Code  | Meaning                                                                                 |
| ----- | --------------------------------------------------------------------------------------- |
| `200` | Task found — check `state`                                                              |
| `401` | Missing or expired JWT                                                                  |
| `404` | Unknown, malformed, or another tenant's task — these are deliberately indistinguishable |

***

## Get vulnerabilities

Returns security vulnerability findings for the specified app.

```http theme={null}
GET /v1/apps/:appId/vulnerabilities
```

If a test is currently in progress, the endpoint returns `status: "RUN_IN_PROGRESS"` rather than findings. Wait for the run to reach `COMPLETED` before fetching results.

<ParamField path="appId" type="string" required>App identifier returned by `GET /apps`.</ParamField>

<ParamField query="page" type="integer" default="1">Page number.</ParamField>
<ParamField query="pageSize" type="integer" default="20">Items per page, max 100.</ParamField>
<ParamField query="search" type="string">Free-text filter across the finding's text fields.</ParamField>
<ParamField query="severity" type="string">Only return findings of this severity (case-insensitive): `Critical`, `High`, `Medium`, or `Low`.</ParamField>
<ParamField query="isFixed" type="boolean">Only return findings Perfai has confirmed fixed (`true`) or not yet fixed (`false`).</ParamField>
<ParamField query="isDismissed" type="boolean">Only return dismissed findings (`true`) or not-dismissed findings (`false`).</ParamField>

Filters combine with **AND**, and apply before paging — so `total` reflects the filtered set.

<RequestExample>
  ```bash cURL theme={null}
  curl -s \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd/vulnerabilities?page=1&pageSize=50"
  ```
</RequestExample>

<ResponseExample>
  ```json Test completed theme={null}
  {
    "status": "COMPLETED",
    "data": [
      {
        "id": "683ccc1111222233334444ab",
        "label": "Broken Object Level Authorization",
        "title": "Any order is readable across tenants",
        "severity": "Critical",
        "path": "/api/v2/orders/{id}",
        "method": "GET",
        "impact": "Attacker can access any order by manipulating the ID parameter.",
        "owasp": "API1:2023",
        "cwe": "CWE-639",
        "cvss_score": 8.1,
        "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
        "isPII": true,
        "isPCI": false,
        "isHIPAA": false,
        "request": "curl -X GET 'https://api.payments.example.com/api/v2/orders/1042' -H 'Authorization: Bearer <tenant-b-token>'",
        "isFixed": false,
        "isDismissed": false,
        "fixClaimed": false,
        "created_on": "2025-10-14T09:15:22.000Z",
        "updated_on": "2025-10-14T09:15:22.000Z"
      }
    ],
    "total": 1,
    "page": 1,
    "pageSize": 20,
    "lastRunId": "683def5678901abcd1234abc",
    "lastRunCompletedAt": "2025-10-14T09:15:30.000Z"
  }
  ```

  ```json Test in progress theme={null}
  {
    "status": "RUN_IN_PROGRESS",
    "message": "A security test is currently in progress. Vulnerability results will be available once it completes.",
    "activeRunId": "683def5678901abcd1234abc",
    "activeRunState": "RUNNING"
  }
  ```

  ```json No tests run theme={null}
  {
    "status": "NO_RUNS",
    "data": [],
    "total": 0,
    "page": 1,
    "pageSize": 20,
    "lastRunId": null,
    "lastRunCompletedAt": null
  }
  ```
</ResponseExample>

<Note>
  An app registered with your own `tenantOneCredentials` or with `createTestAccounts: false` returns `NO_RUNS` until you trigger a test — those paths don't run one automatically. On the default auto sign-up path, the initial onboarding test usually means findings are already present. Treat `NO_RUNS` as "not tested yet" and call [Run Security Agent](#run-security-agent); don't read it as "no vulnerabilities found."
</Note>

### Response

For `status: COMPLETED` or `NO_RUNS`:

<ResponseField name="status" type="&#x22;COMPLETED&#x22; | &#x22;NO_RUNS&#x22;">Result availability.</ResponseField>
<ResponseField name="data" type="Vulnerability[]">Paginated vulnerability findings — see the [Vulnerability object](#vulnerability-object) below.</ResponseField>
<ResponseField name="total" type="integer">Total findings across all pages.</ResponseField>
<ResponseField name="page" type="integer">Current page.</ResponseField>
<ResponseField name="pageSize" type="integer">Page size used for this response.</ResponseField>
<ResponseField name="lastRunId" type="string | null">ID of the most recent completed run.</ResponseField>
<ResponseField name="lastRunCompletedAt" type="ISO 8601 string | null">When the last run completed.</ResponseField>

### Vulnerability object

The vulnerability object carries the same finding detail the Perfai dashboard shows. Only `id`, `label`, and `severity` appear on every finding. **Every other field is conditional** — it is included only when the scan produced a value for it, and usually omitted otherwise (absent from the JSON rather than returned as `null`), so write your integration to treat them as optional. `path` and `method` are present for endpoint-specific findings and may be `null` or omitted for app-level findings (for example a missing rate-limit header or an unauthenticated-exposure finding not tied to one route). How often a field appears depends heavily on the finding type: a request reproducer accompanies most findings, response bodies and schemas a subset, and `explainer`/`remediation` and the compliance flags only a small fraction. The object is flat on the wire — the groups below are for reading only.

<Accordion title="Security categories — the values `label` can take">
  Every finding's `label` is one of Perfai's security categories, aligned to the OWASP API Security Top 10. The current set:

  `API Governance / Inventory`, `BOLA – Cross-Role Access`, `BOLA – Cross-Tenant Role Access`, `BOLA – Same-Tenant Ownership Bypass`, `BOLA – Same-Tenant Write Without Read Access`, `Broken Access Control (BAC) - Inconsistent Permissions`, `Broken Authentication (Expired Token)`, `Broken Authentication (Invalid Credentials)`, `Broken Authentication (Invalid Token)`, `Broken Authentication (No Token)`, `Broken CORS Policy`, `Broken Data Access (BDA) - Inconsistent Permissions`, `Broken Date Range Limit`, `Broken Function Level Authorization`, `Broken Logout`, `Broken Object Level Authorization (BOLA)`, `Broken Pagination Limit`, `Broken Project-Level Authorization (BPLA)`, `Broken Resource-Level Authorization (BRLA)`, `Broken Tenant-Level Authorization (BTLA)`, `Broken Token Claim Manipulation`, `Broken Token Revocation`, `Broken Token Signature Verification`, `Client Side Request Forgery`, `Cost Evasion / Audit Data Tampering`, `Credential Exposure via URL Parameters`, `Cross-Application Token Acceptance`, `Cross-Environment Token Acceptance`, `Cross-Site Scripting (XSS)`, `Cross-Tenant Data Contamination`, `Cross-Tenant Takeover`, `Cross-User Data Contamination`, `Data Access Authorization Anomaly`, `Data Access Breaking Change`, `Debug Endpoint Exposed`, `Enumerable Resource ID`, `Excessive Token Data Exposure`, `Exposed Credentials`, `Hidden Sensitive Data in Authorized Responses`, `Input Validation`, `JNDI Injection`, `Malicious File Acceptance`, `Missing HSTS Header`, `Missing Token Audience Claim`, `Missing Token Issuer Claim`, `Missing Token Revocation`, `Nested Sensitive Data Exposure`, `NoSQL Injection`, `Non-JWT Token Format`, `Open HTTP Redirection`, `Pagination Missing`, `Privilege Escalation`, `Prompt Injection`, `RBAC Matrix Validation`, `SBAC Matrix Validation`, `SQL Injection`, `SSL Certificate Expiration (<15 days)`, `Search Data Leak`, `Search Data Leak (Authorized)`, `Security Configuration Missing`, `Self-Signed SSL Certificate`, `Sensitive Business Flows / Rate Limiting`, `Sensitive Data Exposure`, `Sensitive Data in Error & Metadata Responses`, `Server Side Request Forgery (SSRF)`, `Session Hijacking`, `Shadow Data`, `TLS Version Below 1.2`, `Token & Data Security (Read Operations)`, `Token & Data Security (Write Operations)`, `Token Data Tampering`, `Token Signing-Key Leak`, `UI Workflow Permission Anomalies`, `Unauthorized Token Generation`, `Unrestricted Access to Sensitive Business Flows`, `Unsafe HTTP Method for Authentication Operations`, `Unsecured Observability Endpoints`, `Valid Authentication Baseline`, `Weak Authorization`

  New categories are added as Perfai's detection coverage grows, so treat `label` as an open set of strings rather than a fixed enum.
</Accordion>

#### Identity & location

<ResponseField name="id" type="string" required>Finding identifier — pass it to the dismiss/fix/reopen and [Get vulnerability](#get-vulnerability) endpoints. Always present.</ResponseField>
<ResponseField name="label" type="string" required>Finding name / security category. Always present.</ResponseField>
<ResponseField name="title" type="string">Specific finding title, when the scan set one distinct from the category.</ResponseField>
<ResponseField name="path" type="string">API path where the issue was found. Present for endpoint-specific findings; `null` or omitted for app-level findings.</ResponseField>
<ResponseField name="method" type="string">HTTP method of the affected endpoint. Present for endpoint-specific findings; `null` or omitted for app-level findings.</ResponseField>
<ResponseField name="uiPath" type="string">UI route the finding was reproduced through, when it was discovered via the app UI.</ResponseField>

#### Classification & scoring

<ResponseField name="severity" type="&#x22;Critical&#x22; | &#x22;High&#x22; | &#x22;Medium&#x22; | &#x22;Low&#x22;" required>Risk severity. Always present.</ResponseField>
<ResponseField name="updated_severity" type="string">Severity after a manual override, when one was applied.</ResponseField>
<ResponseField name="impact" type="string">Short business- and security-impact summary.</ResponseField>
<ResponseField name="issueType" type="string">`security` or `privacy`.</ResponseField>
<ResponseField name="action" type="string">Action classification the scanner assigned.</ResponseField>
<ResponseField name="intent" type="string">The intended request behaviour the test exercised.</ResponseField>
<ResponseField name="owasp" type="string">OWASP API Security Top 10 reference.</ResponseField>
<ResponseField name="cwe" type="string">CWE identifier.</ResponseField>
<ResponseField name="cvss_score" type="number">CVSS v3 base score (0.0–10.0).</ResponseField>
<ResponseField name="cvss_vector" type="string">CVSS v3 vector string.</ResponseField>

#### Compliance flags

Present on the fraction of findings the platform classified against a compliance regime; `true` when the finding involves that data class.

<ResponseField name="isPII" type="boolean">Finding involves Personally Identifiable Information.</ResponseField>
<ResponseField name="isPCI" type="boolean">Finding involves Payment Card Industry data.</ResponseField>
<ResponseField name="isHIPAA" type="boolean">Finding involves HIPAA-regulated data.</ResponseField>

#### Explanation & remediation

<ResponseField name="explainer" type="string">Technical explanation of the vulnerability. Populated on a small fraction of findings.</ResponseField>
<ResponseField name="remediation" type="string">Recommended fix. Populated on a small fraction of findings.</ResponseField>
<ResponseField name="additionalDetails" type="object">Deep-dive analysis, when available. Common keys: `description`, `StepsToReproduce`, `impact_analysis`, `mitigation`, `long_solution`, `references`, `conclusion`, `copilot_prompt`, `workingExamples`. Which keys are present varies by finding.</ResponseField>

#### Evidence

<ResponseField name="request" type="string">cURL reproducer for the authenticated request. Present on most findings.</ResponseField>
<ResponseField name="response" type="object | string">Response body observed for the authenticated request.</ResponseField>
<ResponseField name="unsecureRequest" type="string">cURL reproducer for the same call made **without** authentication — present on findings that reproduce unauthenticated.</ResponseField>
<ResponseField name="unsecuredResponse" type="object | string">Response body observed for the unauthenticated request.</ResponseField>
<ResponseField name="requestSchema" type="object">JSON Schema of the request body, when one was derived.</ResponseField>
<ResponseField name="responseSchema" type="object">JSON Schema of the response body, when one was derived.</ResponseField>
<ResponseField name="fields" type="array">Affected or sensitive fields attached to the finding, each with a name, data type, and location.</ResponseField>

#### Triage & fix state

<ResponseField name="isDismissed" type="boolean">Finding has been dismissed as accepted risk. See [Dismiss vulnerability](#dismiss-vulnerability).</ResponseField>
<ResponseField name="isFixed" type="boolean">Set by Perfai once a previously-reported finding stops being detected by a later test. You cannot set this.</ResponseField>
<ResponseField name="fixClaimed" type="boolean">Someone asserted the finding is fixed — a claim pending verification, distinct from `isFixed`. See [Mark vulnerability fixed](#mark-vulnerability-fixed).</ResponseField>
<ResponseField name="fixClaimedAt" type="ISO 8601 string | null">When the fix was claimed.</ResponseField>
<ResponseField name="fixClaimedByEmail" type="string | null">Who claimed the fix.</ResponseField>
<ResponseField name="fixComment" type="string | null">Comment supplied with the fix claim.</ResponseField>
<ResponseField name="isCustomIssue" type="boolean">`true` for a manually created finding rather than one the scanner detected.</ResponseField>

#### Timestamps

<ResponseField name="created_on" type="ISO 8601 string | null">When the finding was first recorded.</ResponseField>
<ResponseField name="updated_on" type="ISO 8601 string | null">When the finding was last updated.</ResponseField>

### Response — test in progress

When a test is active, the response is a small envelope instead of findings (`status: RUN_IN_PROGRESS`):

<ResponseField name="status" type="&#x22;RUN_IN_PROGRESS&#x22;">Indicates a test is currently active.</ResponseField>
<ResponseField name="message" type="string">Human-readable description.</ResponseField>
<ResponseField name="activeRunId" type="string">ID of the in-progress run.</ResponseField>
<ResponseField name="activeRunState" type="&#x22;QUEUED&#x22; | &#x22;RUNNING&#x22;">Current state of the active run.</ResponseField>

### Status codes

| Code  | Meaning                                                                         |
| ----- | ------------------------------------------------------------------------------- |
| `200` | Success — check `status` field for `COMPLETED`, `NO_RUNS`, or `RUN_IN_PROGRESS` |
| `401` | Missing or expired JWT                                                          |
| `404` | App not found or belongs to a different tenant                                  |

***

## Dismiss vulnerability

Marks a finding as accepted risk so it stops appearing in your open findings. Later tests do not re-raise it.

```http theme={null}
POST /v1/vulnerabilities/:vulnerabilityId/dismiss
```

Reverse this with [Reopen vulnerability](#reopen-vulnerability).

<ParamField path="vulnerabilityId" type="string" required>The `id` of a finding from [Get vulnerabilities](#get-vulnerabilities).</ParamField>

<ParamField body="comment" type="string">Why you dismissed it. Max 1000 characters.</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s -X POST \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"comment":"Accepted risk — endpoint is internal-only behind the VPN."}' \
    "https://api.perfai.ai/v1/vulnerabilities/683ccc1111222233334444ab/dismiss"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "status": "DISMISSED",
    "vulnerability": {
      "id": "683ccc1111222233334444ab",
      "label": "Broken Object Level Authorization",
      "severity": "Critical",
      "isDismissed": true,
      "fixClaimed": false
    }
  }
  ```
</ResponseExample>

### Response

<ResponseField name="status" type="&#x22;DISMISSED&#x22;">State of the finding after the action.</ResponseField>
<ResponseField name="vulnerability" type="object">The updated finding — the **full** [Vulnerability object](#vulnerability-object) reflecting its new state (the sample response is abbreviated to the state fields).</ResponseField>

### Status codes

| Code  | Meaning                                            |
| ----- | -------------------------------------------------- |
| `200` | Finding dismissed                                  |
| `400` | Finding is already dismissed                       |
| `401` | Missing or expired JWT                             |
| `403` | Your role is `Viewer` and cannot modify findings   |
| `404` | Finding not found or belongs to a different tenant |
| `429` | Rate limit exceeded (30 requests / 60 s per IP)    |

***

## Mark vulnerability fixed

Records that you have fixed a finding.

```http theme={null}
POST /v1/vulnerabilities/:vulnerabilityId/fix
```

<Note>
  This records a **claim pending verification**, not a verified result. If the next security test still detects the finding, the claim is dropped and the finding stays open. It is reported separately from `isFixed`, which Perfai sets on its own once a finding stops being detected — so `fixClaimed` is what you asserted, and `isFixed` is what Perfai observed.
</Note>

<ParamField path="vulnerabilityId" type="string" required>The `id` of a finding from [Get vulnerabilities](#get-vulnerabilities).</ParamField>

<ParamField body="comment" type="string">Notes about the fix. Max 1000 characters.</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s -X POST \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"comment":"Parameterised the query in PR #482."}' \
    "https://api.perfai.ai/v1/vulnerabilities/683ccc1111222233334444ab/fix"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "status": "FIX_CLAIMED",
    "vulnerability": {
      "id": "683ccc1111222233334444ab",
      "label": "Broken Object Level Authorization",
      "severity": "Critical",
      "isFixed": false,
      "isDismissed": false,
      "fixClaimed": true,
      "fixClaimedAt": "2025-10-15T11:02:41.000Z",
      "fixClaimedByEmail": "dev@example.com",
      "fixComment": "Parameterised the query in PR #482."
    }
  }
  ```
</ResponseExample>

### Response

<ResponseField name="status" type="&#x22;FIX_CLAIMED&#x22;">State of the finding after the action.</ResponseField>
<ResponseField name="vulnerability" type="object">The updated finding — the **full** [Vulnerability object](#vulnerability-object), carrying the `fixClaimed*` fields (the sample response is abbreviated to the state fields).</ResponseField>

### Status codes

| Code  | Meaning                                                            |
| ----- | ------------------------------------------------------------------ |
| `200` | Fix claim recorded                                                 |
| `400` | Finding is already marked fixed, or is dismissed — reopen it first |
| `401` | Missing or expired JWT                                             |
| `403` | Your role is `Viewer` and cannot modify findings                   |
| `404` | Finding not found or belongs to a different tenant                 |
| `429` | Rate limit exceeded (30 requests / 60 s per IP)                    |

***

## Reopen vulnerability

Returns a finding to your open findings. Undoes a dismissal or a fix claim — whichever state the finding is in.

```http theme={null}
POST /v1/vulnerabilities/:vulnerabilityId/reopen
```

<ParamField path="vulnerabilityId" type="string" required>The `id` of a finding from [Get vulnerabilities](#get-vulnerabilities).</ParamField>

Takes no request body.

<RequestExample>
  ```bash cURL theme={null}
  curl -s -X POST \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/vulnerabilities/683ccc1111222233334444ab/reopen"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "status": "OPEN",
    "vulnerability": {
      "id": "683ccc1111222233334444ab",
      "label": "Broken Object Level Authorization",
      "severity": "Critical",
      "isDismissed": false,
      "fixClaimed": false
    }
  }
  ```
</ResponseExample>

### Response

<ResponseField name="status" type="&#x22;OPEN&#x22;">State of the finding after the action.</ResponseField>
<ResponseField name="vulnerability" type="object">The updated finding — the **full** [Vulnerability object](#vulnerability-object) reflecting its new state (the sample response is abbreviated to the state fields).</ResponseField>

### Status codes

| Code  | Meaning                                            |
| ----- | -------------------------------------------------- |
| `200` | Finding reopened                                   |
| `400` | Finding is already open                            |
| `401` | Missing or expired JWT                             |
| `403` | Your role is `Viewer` and cannot modify findings   |
| `404` | Finding not found or belongs to a different tenant |
| `429` | Rate limit exceeded (30 requests / 60 s per IP)    |

***

## Get vulnerability

Returns a single finding by id, scoped to your organization. Use it to re-read one finding after acting on it, without paging the whole list.

```http theme={null}
GET /v1/vulnerabilities/:vulnerabilityId
```

<ParamField path="vulnerabilityId" type="string" required>
  The `id` of a finding from [Get vulnerabilities](#get-vulnerabilities).
</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/vulnerabilities/683ccc1111222233334444ab"
  ```
</RequestExample>

The response is a single [Vulnerability object](#vulnerability-object) — the same shape as an item in the [Get vulnerabilities](#get-vulnerabilities) `data` array.

<Note>
  Full finding detail is a paid feature. On the free tier this returns `402` — upgrade to fetch a finding by id.
</Note>

### Status codes

| Code  | Meaning                                                  |
| ----- | -------------------------------------------------------- |
| `200` | The finding                                              |
| `401` | Missing or expired JWT                                   |
| `402` | Full finding detail is a paid feature — upgrade required |
| `404` | Finding not found or belongs to a different tenant       |

***

## Get reports

Lists the security reports generated for an app, newest first. Each item includes a `downloadUrl` for fetching the PDF.

```http theme={null}
GET /v1/apps/:appId/reports
```

Security reports are a paid feature. On the free tier this returns `402` — upgrade to list and download reports.

<ParamField path="appId" type="string" required>App identifier returned by `GET /apps`.</ParamField>

<ParamField query="page" type="integer" default="1">Page number.</ParamField>
<ParamField query="pageSize" type="integer" default="20">Items per page, max 100.</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.perfai.ai/v1/apps/683abc1234def5678901abcd/reports?page=1&pageSize=20"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "data": [
      {
        "reportId": "683def5678901abcd1234abc",
        "appId": "683abc1234def5678901abcd",
        "reportType": "security",
        "format": "pdf",
        "issuesCount": 42,
        "apiName": "Payments API",
        "runNumber": 7,
        "createdAt": "2026-07-04T09:00:00.000Z",
        "createdByEmail": "ci@acme.example",
        "downloadUrl": "https://api.perfai.ai/v1/reports/683def5678901abcd1234abc"
      }
    ],
    "total": 1,
    "page": 1,
    "pageSize": 20
  }
  ```
</ResponseExample>

### Response

<ResponseField name="data" type="Report[]">
  Security reports — newest first. An app with no reports returns an empty array.

  <Expandable title="Report">
    <ResponseField name="reportId" type="string">Report identifier — pass it to [Download report](#download-report).</ResponseField>
    <ResponseField name="appId" type="string">App the report belongs to.</ResponseField>
    <ResponseField name="reportType" type="string">Always `security` on this endpoint.</ResponseField>
    <ResponseField name="format" type="string">Report format — `pdf`.</ResponseField>
    <ResponseField name="issuesCount" type="integer">Number of findings covered by the report.</ResponseField>
    <ResponseField name="apiName" type="string">Human-readable app name.</ResponseField>
    <ResponseField name="runNumber" type="integer">The security-test run the report was generated from.</ResponseField>
    <ResponseField name="createdAt" type="ISO 8601 string">When the report was generated.</ResponseField>
    <ResponseField name="createdByEmail" type="string">Who triggered the run.</ResponseField>
    <ResponseField name="downloadUrl" type="string">Absolute [Download report](#download-report) URL for the PDF.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="total" type="integer">Total reports across all pages.</ResponseField>
<ResponseField name="page" type="integer">Current page.</ResponseField>
<ResponseField name="pageSize" type="integer">Page size used for this response.</ResponseField>

### Status codes

| Code  | Meaning                                        |
| ----- | ---------------------------------------------- |
| `200` | Reports listed (may be empty)                  |
| `401` | Missing or expired JWT                         |
| `402` | Reports are a paid feature — upgrade required  |
| `404` | App not found or belongs to a different tenant |

***

## Download report

Downloads a security report as a PDF. Store the bytes wherever you like.

```http theme={null}
GET /v1/reports/:reportId
```

On success the response body is the raw PDF (`Content-Type: application/pdf`), not JSON. Security reports are a paid feature. On the free tier this returns `402` — upgrade to download reports.

<ParamField path="reportId" type="string" required>
  Report identifier from [Get reports](#get-reports).
</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -s \
    -H "Authorization: Bearer $TOKEN" \
    -o security-report.pdf \
    "https://api.perfai.ai/v1/reports/683def5678901abcd1234abc"
  ```
</RequestExample>

On success (`200 OK`) the response body is the raw PDF (`Content-Type: application/pdf`), sent as an attachment — not JSON.

### Status codes

| Code  | Meaning                                           |
| ----- | ------------------------------------------------- |
| `200` | The report PDF                                    |
| `401` | Missing or expired JWT                            |
| `402` | Reports are a paid feature — upgrade required     |
| `404` | Report not found or belongs to a different tenant |
