---
title: Create a pentest draft
description: Create a pentest that hasn't started.
url: https://www.hacktron.ai/docs/api/pentests/create-pentest
---

# Create a pentest draft

Creates a draft with the code you want tested and any deployed sites you want to test. Creating a draft doesn't start a pentest or spend credits. You review the estimate before you start it with [`POST /pentests/{id}/start`](/docs/api/pentests/start-pentest). You need the member role or above.

## Set up and start a pentest

1. Create the draft with this endpoint, and change it with [`PATCH /pentests/{id}`](/docs/api/pentests/update-pentest).
2. If the draft has `login_targets`, call [`POST /pentests/{id}/login-checks`](/docs/api/pentests/check-pentest-logins) until each login passes.
3. Get the credit cost with [`POST /pentests/{id}/estimate`](/docs/api/pentests/estimate-pentest).
4. Start it with [`POST /pentests/{id}/start`](/docs/api/pentests/start-pentest), or delete it with [`DELETE /pentests/{id}`](/docs/api/pentests/discard-pentest).

Save the `scan_id` from the response. Use the same ID throughout setup and after the pentest starts, including with [`GET /scans/{id}`](/docs/api/scans/get-scan).

## What to test

- `repos` (at least one): connected repositories, public repositories, or archives uploaded with [`POST /archives/uploads`](/docs/api/archives/request-archive-upload). Set a repository's `include_paths` and `exclude_paths` to test only part of the code.
- `target_urls`: deployed sites to test without signing in.
- `login_targets`: deployed sites to test signed in. Each has a `site` and `login_instructions`, which give the login page, username, password, and any extra steps. Login instructions are stored encrypted and never returned.

A draft can have `target_urls` or `login_targets`, but not both. To test sites both with and without a login, create separate pentests.

:::note
Signed-in testing is enabled per organisation. If it isn't enabled for yours, a request with `login_targets` returns `400`.
:::

## Request

`POST /pentests`

Send an [API key](/docs/api/authentication) with the `write` scope in the `X-Api-Key` header.

:::examples

```bash title="Request" wrap=false
curl -X POST "https://api.hacktron.ai/v1/pentests" \
  -H "X-Api-Key: $HACKTRON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "repos": [
    {
      "include_paths": [
        "services/gateway/**"
      ],
      "exclude_paths": [
        "**/tests/**",
        "vendor/",
        "*.md"
      ],
      "source": "connected",
      "url": "https://github.com/owner/repo",
      "branch": "string"
    }
  ]
}'
```

```json title="Response 201 Created" showLineNumbers=false wrap=false
{
  "scan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "pending",
  "repos": [
    {
      "include_paths": [
        "services/gateway/**"
      ],
      "exclude_paths": [
        "**/tests/**",
        "vendor/",
        "*.md"
      ],
      "source": "connected",
      "provider": "github",
      "url": "https://github.com/owner/repo",
      "branch": "string",
      "ref_type": "branch",
      "archive_id": "string",
      "github_installation_id": 0
    }
  ],
  "target_urls": [
    "string"
  ],
  "login_targets": [
    {
      "site": "string",
      "login_check": "unverified",
      "failure_reason": "string"
    }
  ],
  "custom_context": "string",
  "application_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "cost_estimation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "triage_enabled": true,
  "credits_used": 0
}
```

:::

### Body

A required `application/json` body.

| Field | Type | Description |
| --- | --- | --- |
| `target_urls` | array of string | Target URLs (pentest only). |
| `custom_context` | string | Custom context (pentest only). |
| `application_id` | string | iva application this scan targets (group of repos). |
| `login_targets` | array of object | Deployed sites to test signed in, each with how to sign in. On update the list replaces the saved one, and [] removes them. A pentest tests live sites either all signed in or all without a login (target_urls). Check each login with POST /pentests/{id}/login-checks before starting. At most 10 items. |
| `login_targets[].site` | string, required | A deployed copy of the app to test signed in, e.g. https://staging.acme.dev. At most 2048 characters. |
| `login_targets[].login_instructions` | string, required | How to sign in: the login page, username, password and any steps. Stored encrypted and never returned. At most 2000 characters. |
| `repos` | array of object, required | Repositories or uploaded archives to test. At least 1 item. |
| When `source` is `connected` | | |
| `repos[].include_paths` | array of string | Whitebox scope: gitignore-style globs (relative to the repo root) that narrow the scan to matching paths. Empty means the whole repository. |
| `repos[].exclude_paths` | array of string | Whitebox scope: gitignore-style globs removed from the scan after include_paths is applied; use `*.md` to drop a file type. |
| `repos[].source` | string, required | Always `connected`. |
| `repos[].url` | string, required | Repository URL. |
| `repos[].branch` | string, required | Branch name, tag name, or commit SHA. |
| `repos[].ref_type` | string | How `branch` was selected (UI hint for draft re-use). One of `branch`, `tag`, or `sha`. |
| `repos[].github_installation_id` | number | GitHub App installation id. Omitted for PAT-based connections (e.g. GitHub Enterprise), where the installation is resolved from the repository URL and the organization instead. |
| When `source` is `public` | | |
| `repos[].include_paths` | array of string | Whitebox scope: gitignore-style globs (relative to the repo root) that narrow the scan to matching paths. Empty means the whole repository. |
| `repos[].exclude_paths` | array of string | Whitebox scope: gitignore-style globs removed from the scan after include_paths is applied; use `*.md` to drop a file type. |
| `repos[].source` | string, required | Always `public`. |
| `repos[].url` | string, required | Repository URL. |
| `repos[].branch` | string, required | Branch name, tag name, or commit SHA. |
| `repos[].ref_type` | string | How `branch` was selected (UI hint for draft re-use). One of `branch`, `tag`, or `sha`. |
| When `source` is `upload` | | |
| `repos[].include_paths` | array of string | Whitebox scope: gitignore-style globs (relative to the repo root) that narrow the scan to matching paths. Empty means the whole repository. |
| `repos[].exclude_paths` | array of string | Whitebox scope: gitignore-style globs removed from the scan after include_paths is applied; use `*.md` to drop a file type. |
| `repos[].source` | string, required | Always `upload`. |
| `repos[].archive_id` | string, required | Scan archive ID. |

## Responses

### 201 Created

| Field | Type | Description |
| --- | --- | --- |
| `scan_id` | string (uuid), required | Stable pentest id; task_id changes when it starts. |
| `status` | string, required | One of `pending`, `running`, `completed`, `failed`, `stopped`, `cancelled`, `skipped`, `pending_verification`, `pending_triage`, `draft`, `estimating`, or `estimated`. |
| `repos` | array of object, required | |
| `repos[].include_paths` | array of string | Whitebox scope: gitignore-style globs (relative to the repo root) that narrow the scan to matching paths. Empty means the whole repository. |
| `repos[].exclude_paths` | array of string | Whitebox scope: gitignore-style globs removed from the scan after include_paths is applied; use `*.md` to drop a file type. |
| `repos[].source` | string | One of `connected`, `public`, or `upload`. |
| `repos[].provider` | string, read-only | Resolved by the server at scan start; ignored on input. One of `github`, `gitlab`, `bitbucket`, or `azure`. |
| `repos[].url` | string | Repository URL. |
| `repos[].branch` | string | Branch name, tag name, or commit SHA. |
| `repos[].ref_type` | string | How `branch` was selected (UI hint for draft re-use). One of `branch`, `tag`, or `sha`. |
| `repos[].archive_id` | string | Scan archive ID. |
| `repos[].github_installation_id` | number | GitHub App installation ID (connected repos only). |
| `target_urls` | array of string, required | |
| `login_targets` | array of object, required | |
| `login_targets[].site` | string, required | |
| `login_targets[].login_check` | string, required | Whether Hacktron could sign in with the login the site has now. A pass lasts an hour, and changing the site or login makes it unverified. One of `unverified`, `pending`, `running`, `succeeded`, `failed`, `cancelled`, or `expired`. |
| `login_targets[].failure_reason` | string, required, nullable | Why the check failed, without credentials. |
| `custom_context` | string, required, nullable | |
| `application_id` | string (uuid), required, nullable | |
| `cost_estimation_id` | string (uuid), required, nullable | |
| `triage_enabled` | boolean, required, nullable | |
| `credits_used` | number, required, nullable | |
