Pentests
Start an AI pentest, follow it, read its findings and see what it cost.
An API pentest is the same run you would start from the dashboard: same levels, same prices, same report.
Before the first run:
- Verify the target in the dashboard, the same way as for any pentest.
- Turn on API runs for it under your API keys in Settings. See Targets for API pentests.
- Copy the target's verification id from the same targets table, in the Verification id column. You pass it as
verificationIdwhen you start a run.
Types
Every pentest endpoint answers with these shapes.
type PentestStatus =
| "QUEUED"
| "VERIFYING" | "RECON" | "ENUMERATION" | "WEB_TEST" | "API_TEST" | "ANALYSIS" | "REPORTING"
| "COMPLETED" | "FAILED" | "CANCELLED" // only these three mean the run is over
// Only on runs started from the dashboard; the API never creates them:
| "AWAITING_PAYMENT" // waiting for a checkout to be paid
| "PAUSED"; // ran out of credits partway; can be resumed from the dashboard
type Pentest = {
id: string;
targetUrl: string;
status: PentestStatus;
depth: "LEAD" | "STANDARD" | "DEEP" | "EXTENDED" | "MAXIMUM"; // a run started from the API is one of the first three
apiKeyId: string | null; // the key that started it, null for a dashboard run
createdAt: string; // ISO 8601
startedAt: string | null;
completedAt: string | null;
reportReleased: boolean; // whether you may read its findings yet, see "Read the findings"
credits: PentestCredits;
};
type PentestCredits = {
state: "RESERVED" | "CHARGED" | "RETURNED" | "NOT_CREDIT_FUNDED" | "AWAITING_PAYMENT";
reserved: number | null; // credits held at start
charged: number | null; // null until the run is settled
returned: number | null; // null until the run is settled
};
type PentestFinding = {
id: string;
title: string;
description: string | null;
severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "INFO";
confidence: "CONFIRMED" | "FIRM" | "TENTATIVE";
affectedUrl: string | null;
remediation: string | null;
cvssScore: string | null;
cweId: string | null;
};Start a pentest
POST /v1/pentests · permission pentests:write · requires an Idempotency-Key header
Starts a run and holds its level's credits. Answers 201 with the run's id, which is how you follow it.
Request body
Prop
Type
Any other field is refused rather than ignored, so a typo fails loudly. The run's budget, duration and model come from the level and cannot be set.
Example
curl -X POST https://api.barrion.io/v1/pentests \
-H "Authorization: Bearer $BARRION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: release-2026-09-28" \
-d '{
"targetUrl": "https://staging.example.com",
"verificationId": "6aca3f16-78aa-4e04-a77a-425a8b3d67ad",
"depth": "LEAD"
}'const res = await fetch("https://api.barrion.io/v1/pentests", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BARRION_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "release-2026-09-28",
},
body: JSON.stringify({
targetUrl: "https://staging.example.com",
verificationId: "6aca3f16-78aa-4e04-a77a-425a8b3d67ad",
depth: "LEAD",
}),
});
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).code}`);
const { id } = (await res.json()) as { id: string; status: string };import os, requests
res = requests.post(
"https://api.barrion.io/v1/pentests",
headers={
"Authorization": f"Bearer {os.environ['BARRION_API_KEY']}",
"Idempotency-Key": "release-2026-09-28",
},
json={
"targetUrl": "https://staging.example.com",
"verificationId": "6aca3f16-78aa-4e04-a77a-425a8b3d67ad",
"depth": "LEAD",
},
)
res.raise_for_status()
pentest_id = res.json()["id"]Response 201
type StartPentestResponse = { id: string; status: PentestStatus };{ "id": "3f9e2c7a-1b4d-4e8f-9a2c-5d6e7f8a9b0c", "status": "QUEUED" }A retry with the same Idempotency-Key and body answers the same run, with the header Idempotent-Replayed: true, and starts nothing. See Idempotency.
Errors
| Status | Code | Why | What to do |
|---|---|---|---|
| 400 | VALIDATION_ERROR | A field is missing or invalid; field names it | Fix the request |
| 402 | INSUFFICIENT_CREDITS | Your balance does not cover the whole level | Top up, or pick a lighter level |
| 403 | TARGET_API_RUNS_DISABLED | API runs are off for this target | Turn them on in Settings |
| 403 | API_KEY_CAP_EXCEEDED | The run would take this key past its budget | Raise the key's budget |
| 403 | ORG_CREDIT_CAP_EXCEEDED | The run would take you past your allowance in the organization | Ask an organization admin |
| 403 | FORBIDDEN | The target's verification is not complete, has expired, or does not cover targetUrl | Re-verify the target in the dashboard |
| 403 | FORBIDDEN | In an organization, targetUrl is not one of the pentest targets assigned to you | Ask an organization admin for access |
| 404 | NOT_FOUND | No target verification with this verificationId in your account | Copy the id from the targets table under API keys |
| 409 | IDEMPOTENCY_IN_PROGRESS | The first request with this key is still running | Retry the same request shortly |
| 422 | IDEMPOTENCY_KEY_REUSED | This key was already used with a different body | Use a new key for a new run |
| 429 | PENTEST_CONCURRENCY_EXCEEDED | You already have as many pentests running as your account allows | Wait for one to finish |
| 429 | RATE_LIMITED | Too many starts in a minute | Wait for Retry-After |
| 500 | INTERNAL_ERROR | The run could not be dispatched | Retry with the same Idempotency-Key |
A pentest is only started when your balance covers its whole level. The dashboard can start a run on part of a level and let you top up to continue; the API never does, because a pipeline cannot top up halfway through.
Authenticated runs
To test behind a login, pass test accounts in credentials, taken from your CI secret store on every run.
type Credential = {
name: string; // unique within the run; letters, digits, _ and -
kind?: "FORM_LOGIN" | "BEARER_TOKEN" | "BASIC_AUTH" | "COOKIE" | "API_KEY"; // default FORM_LOGIN
user: string; // username, or the header or parameter name for API_KEY
pass: string; // password, token or key value
totpSecret?: string; // authenticator-app secret, if the login needs one
instructions?: string; // non-secret guidance, e.g. "after login, open /profile"
mode?: "auto" | "json" | "form" | "browser"; // FORM_LOGIN: how the login is submitted; default auto
location?: "header" | "query"; // API_KEY: where the key is sent; default header
tokenUrl?: string; // BEARER_TOKEN: OAuth2 token URL for the client-credentials flow
scope?: string; // BEARER_TOKEN with tokenUrl: scope to request
audience?: string; // BEARER_TOKEN with tokenUrl: audience to request
};For BEARER_TOKEN without tokenUrl, pass is the token itself. With tokenUrl, user is the client id and pass the client secret, and Barrion requests the token and refreshes it during the run.
Credentials are encrypted as they arrive and are never shown back to you or written to logs. They stay stored, encrypted, so Barrion's reviewers can verify findings behind the login and a retest of the same run can reuse them. A daily cleanup deletes them once they are 30 days old. Use dedicated test accounts, not real users' logins.
Get a pentest
GET /v1/pentests/{id} · permission pentests:read
The run's status and what it has cost so far. This is what a pipeline polls.
curl https://api.barrion.io/v1/pentests/3f9e2c7a-1b4d-4e8f-9a2c-5d6e7f8a9b0c \
-H "Authorization: Bearer $BARRION_API_KEY"const res = await fetch(`https://api.barrion.io/v1/pentests/${id}`, {
headers: { Authorization: `Bearer ${process.env.BARRION_API_KEY}` },
});
const pentest = (await res.json()) as Pentest;res = requests.get(
f"https://api.barrion.io/v1/pentests/{pentest_id}",
headers={"Authorization": f"Bearer {os.environ['BARRION_API_KEY']}"},
)
pentest = res.json()Response 200: Pentest
{
"id": "3f9e2c7a-1b4d-4e8f-9a2c-5d6e7f8a9b0c",
"targetUrl": "https://staging.example.com",
"status": "WEB_TEST",
"depth": "LEAD",
"apiKeyId": "b81d4f0e-2c3a-4b5d-8e9f-0a1b2c3d4e5f",
"createdAt": "2026-09-28T10:02:11.000Z",
"startedAt": "2026-09-28T10:04:40.000Z",
"completedAt": null,
"reportReleased": true,
"credits": { "state": "RESERVED", "reserved": 400, "charged": null, "returned": null }
}A run can wait in QUEUED for a while before it starts: it is waiting for a free slot. FAILED runs are not charged, and CANCELLED ones only for the work they got through.
credits.state:
| State | Meaning |
|---|---|
RESERVED | Some of the run's credits are still held. charged and returned are null until all of them are settled |
CHARGED | Settled. charged credits were spent and returned went back to your balance |
RETURNED | Failed, or cancelled before it started. Everything reserved went back |
NOT_CREDIT_FUNDED | Paid for another way, for example included in your plan |
AWAITING_PAYMENT | A dashboard run waiting for its checkout. Nothing is reserved yet |
A run can be COMPLETED and still read RESERVED, for two reasons. Settling a finished run takes up to a minute or so. And on Standard and Deep, the part of the credits that pays for the expert review stays held until a reviewer signs the report off. See Credits.
Errors
404 NOT_FOUND when there is no such run in this account. Retests are not in the API, so a retest's id is also a 404.
List pentests
GET /v1/pentests · permission pentests:read
Your runs, newest first.
Prop
Type
curl "https://api.barrion.io/v1/pentests?limit=20" \
-H "Authorization: Bearer $BARRION_API_KEY"Response 200
type ListPentestsResponse = {
pentests: Pentest[];
nextOffset: number | null; // null on the last page
};Read the findings
GET /v1/pentests/{id}/findings · permission pentests:read
Prop
Type
curl "https://api.barrion.io/v1/pentests/3f9e2c7a-1b4d-4e8f-9a2c-5d6e7f8a9b0c/findings?severity=critical,high" \
-H "Authorization: Bearer $BARRION_API_KEY"const res = await fetch(
`https://api.barrion.io/v1/pentests/${id}/findings?severity=critical,high`,
{ headers: { Authorization: `Bearer ${process.env.BARRION_API_KEY}` } }
);
const { reportReleased, total, findings } = await res.json();res = requests.get(
f"https://api.barrion.io/v1/pentests/{pentest_id}/findings",
params={"severity": "critical,high"},
headers={"Authorization": f"Bearer {os.environ['BARRION_API_KEY']}"},
)
data = res.json()Response 200
type PentestFindingsResponse = {
id: string;
reportReleased: boolean;
total: number | null; // null while the report is not released
nextOffset: number | null;
findings: PentestFinding[]; // worst first; empty while the report is not released
};{
"id": "3f9e2c7a-1b4d-4e8f-9a2c-5d6e7f8a9b0c",
"reportReleased": true,
"total": 1,
"nextOffset": null,
"findings": [
{
"id": "c1d0e2f3-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"title": "SQL injection in the search parameter",
"description": "...",
"severity": "CRITICAL",
"confidence": "CONFIRMED",
"affectedUrl": "https://staging.example.com/search?q=",
"remediation": "...",
"cvssScore": "9.8",
"cweId": "CWE-89"
}
]
}Findings follow the same release rule as the dashboard, and reportReleased says whether you may see them yet. It is not the same as the run being finished:
- Light includes no expert review, so its findings are released as soon as the run is paid for, and fill in while it runs.
reportReleasedistruebefore the run isCOMPLETED. - Standard and Deep include an expert review. Nothing is released until a Barrion reviewer has signed the report off, which can be some time after the run reaches
COMPLETED. - A run that ended
FAILEDorCANCELLEDshows whatever it found, without review.
So to judge a run, wait for both: status is COMPLETED and reportReleased is true. Only findings that made it into the report are listed.
If a reviewer rejects a Standard or Deep report, reportReleased stays false, and the API cannot yet tell that apart from a review still in progress. If a report is not released within a few days of the run completing, check the run in the dashboard.
Cancel a pentest
POST /v1/pentests/{id}/cancel · permission pentests:write
curl -X POST https://api.barrion.io/v1/pentests/3f9e2c7a-1b4d-4e8f-9a2c-5d6e7f8a9b0c/cancel \
-H "Authorization: Bearer $BARRION_API_KEY" \
-d ''The request has no body, but it must still send Content-Length: 0, which -d '' does. Without it the load balancer answers 411 Length Required.
Response 200
type CancelPentestResponse = {
success: true;
status: "COMPLETED" | "FAILED" | "CANCELLED"; // the run's status after the call
};A cancelled run is charged only for the work it got through, and nothing if it had not started. Cancelling a run that has already ended is not an error: it answers { "success": true, "status": "COMPLETED" } and changes nothing, so a pipeline's cleanup step can always call it.