Engagements

Creating, reading and submitting engagements, and reading their findings, through the partner API.

An engagement is one test run: targets, scope, rules of engagement and a framework. Creating an engagement and starting it are two calls. Create stores it at pending; submit sends it through the launch gate and, if every identifier's ownership proof holds, queues it.

Method Path Success
POST /api/v1/engagements 201 and the engagement
GET /api/v1/engagements 200 and a list of engagements, newest first
GET /api/v1/engagements/{engagement_id} 200 and the engagement
GET /api/v1/engagements/{engagement_id}/findings 200 and a list of findings
POST /api/v1/engagements/{engagement_id}/submit 200 and the engagement, now queued
GET /api/v1/engagements/{engagement_id}/report See Reports

There is no endpoint to update, cancel or delete an engagement. An engagement that belongs to another organization is answered 404 Engagement not found, the same as one that does not exist. The list covers the whole organization, including engagements created in the console or by a schedule.

Creating an engagement

curl -X POST https://<pentr-host>/api/v1/engagements \
  -H "X-API-Key: $PENTR_API_KEY" -H "Content-Type: application/json" \
  -d @engagement.json
{
  "targets": [{"host": "example.com", "type": "domain"}],
  "scope": {
    "in_scope": ["app.example.com"],
    "out_of_scope": ["mail.example.com"]
  },
  "rules": {
    "max_duration_minutes": 120,
    "allowed_actions": ["scan", "enumerate"],
    "denied_actions": ["exploit", "post_exploit"]
  },
  "framework": "soc2"
}
Field Type Required What the server does with it
targets list of objects yes Stores each host as a string
targets[].host string yes Stored as sent; checked at submit
targets[].type string yes Required, then discarded; the launch gate takes each target's kind from the asset that covers it
scope.in_scope list of strings yes Stored as sent; every entry is checked at submit
scope.out_of_scope list of strings yes Stored as sent; never checked for ownership
rules.max_duration_minutes integer yes Stored; any integer is accepted, and the run does not stop at it
rules.allowed_actions list of strings yes Stored
rules.denied_actions list of strings yes Stored
framework string yes Stored; not checked against the framework files
credentials list of objects no Accepted and never read

Fields the schema does not name are ignored. The meaning of the scope lists is in Scope; the rules, including why the duration is advisory, are in Rules of engagement.

The console sends one of six framework values, the stems of the framework files: soc2, iso27001, pci_dss, owasp_asvs, nist_csf, ens. The API stores any string of up to 50 characters, the length of its column. A value one character over the limit is refused by the database and answered with a plain-text 500. A value over the limit whose excess characters are all spaces is truncated to 50 characters and the request succeeds. See Choosing a framework and Custom frameworks.

A body that fails validation is answered 422 with a list under detail:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "targets", 0, "type"],
      "msg": "Field required",
      "input": {"host": "x"}
    }
  ]
}

The engagement object

Create, read, list and submit return the same object:

{
  "id": "100ab009-2e64-48f7-b3d9-49a015caaf5f",
  "targets": ["example.com"],
  "scope_in": ["app.example.com"],
  "scope_out": ["mail.example.com"],
  "rules": {
    "max_duration_minutes": 120,
    "allowed_actions": ["scan", "enumerate"],
    "denied_actions": ["exploit", "post_exploit"]
  },
  "framework": "soc2",
  "status": "pending",
  "created_at": "2026-09-23T09:42:30.880382",
  "started_at": null,
  "completed_at": null
}

The object does not mirror the request. targets is a list of host strings, not of objects, and the scope lists are returned as scope_in and scope_out rather than inside a scope object. credentials is not returned. To create an engagement from one you have read, rebuild the request shape.

started_at is set when the run's container starts, and completed_at when the run ends as completed or failed. Both are null until then.

Statuses

status is one of seven values: pending, queued, running, completed, failed, cancelled, blocked. No code path sets cancelled. Lifecycle describes each status and the transitions between them.

Submitting an engagement

POST /api/v1/engagements/{engagement_id}/submit takes no body. It is accepted only for an engagement at pending or blocked; any other status is answered 400:

{"detail": "Cannot submit engagement with status 'queued'"}

Submit re-proves ownership of every target and every in-scope entry, as described in Ownership verification. If every one is covered by an asset whose proof holds, the engagement is queued and the answer is 200 with the engagement at queued.

If any identifier fails, nothing is queued, the engagement is set to blocked, and the answer is 409 with an object under detail:

{
  "detail": {
    "message": "Ownership could not be verified for every target.",
    "blocked": [
      {
        "identifier": "staging.example.net",
        "reason": "not in this organization's verified asset inventory",
        "outcome": "fail"
      }
    ]
  }
}

blocked lists each identifier that failed, with the reason and an outcome of fail or error. An engagement with no targets and no in-scope entries is refused with one entry whose identifier is (engagement scope), since there is nothing to prove ownership of. A blocked engagement can be submitted again once the missing proof is in place. Every network check the gate makes is recorded in the covering asset's verification log, whether the submit succeeds or not.

Submit is not subject to the plan cap. A submit through the partner API is never refused because the organization already has engagements queued or running; the cap is checked only when a schedule fires. See Caps and skips.

If the gate passes but the job cannot be put on the queue, the answer is a plain-text 500 Internal Server Error and the engagement keeps the status it had: pending, or blocked for a resubmitted engagement.

Findings

GET /api/v1/engagements/{engagement_id}/findings returns the engagement's findings, oldest first. The list is empty until the run is completed: findings are written when the run is marked completed.

[
  {
    "id": "c6601195-1807-4d10-adbe-35097a61c333",
    "title": "Directory listing enabled",
    "description": "…",
    "severity": "medium",
    "affected_asset": "example.com",
    "engagement_id": "100ab009-2e64-48f7-b3d9-49a015caaf5f",
    "evidence": [{"command": "curl -s https://example.com/files/"}],
    "remediation": "Disable autoindex.",
    "phase": "vuln_analysis",
    "status": "unverified",
    "cvss_score": 5.3,
    "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
    "cves": [],
    "attack_technique_ids": ["T1083"],
    "attack_tactic_ids": ["TA0007"],
    "nist_test_category": "vuln_scanning",
    "created_at": "2026-09-23T09:37:05.000031"
  }
]

Every key is present. cvss_score and cvss_vector are null when the finding has none. The stored finding has three further fields that this endpoint does not return: wstg_ids, compliance_refs and related_finding_ids. Anatomy of a finding describes each field.

status is the finding's current status, including any change an operator made in the console after the run. The partner API can read it but not set it: there is no endpoint that changes a finding.

Last updated 2026-10-11