Our engineers set up and run your first AI security scan. Get in touch

Reports API

← All docs

A report aggregates the scans in a date window into a single document (PDF or DOCX) with the finding detail and framework mapping an auditor asks for. Generation runs in a background worker, so the API is asynchronous: queue a report, poll its status, then download the document.

All four routes are tenant-scoped by the bearer token. A report belonging to another tenant is indistinguishable from one that does not exist.

RouteScope
POST /api/v2/reportsreports:write
GET /api/v2/reportsreports:read
GET /api/v2/reports/{id}reports:read
GET /api/v2/reports/{id}/downloadreports:read

reports:write is deliberately separate from reports:read. Generation renders a document and can span the tenant's entire scan history, so a token that only needs to poll status or fetch a finished document should not be able to queue work. Issue read-only tokens to dashboards and reserve the write scope for the job that requests documents.

Queue a report

curl -sS -X POST https://penaxtra.com/api/v2/reports \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Q2 2026 evidence pack",
    "type": "compliance_evidence",
    "format": "pdf",
    "range": "custom",
    "date_from": "2026-04-01",
    "date_to": "2026-06-30",
    "endpoint_ids": ["11a3dd55-5369-4020-8d11-2652c26fe0d1"]
  }'
{
  "id": "affd91c5-6c1e-4a2f-9a10-2d4e7c8b3f52",
  "status": "queued",
  "type": "compliance_evidence",
  "format": "pdf",
  "date_from": "2026-04-01T00:00:00Z",
  "date_to": "2026-06-30T23:59:59Z",
  "scan_count": 24
}

202 Accepted means the row was written and handed to the worker. It does not mean the document exists yet.

Request body

Every field is optional. An empty body queues a 30-day full-scan report in PDF.

FieldTypeDefaultNotes
namestringgenerated from the date rangeMaximum 200 characters. Control characters are stripped.
typestringfull_scanSee the table below.
formatstringpdfpdf or docx. Both carry identical data.
rangestring30d7d, 30d, 90d, or custom.
date_fromstringYYYY-MM-DD. Required when range is custom.
date_tostringYYYY-MM-DD. Required when range is custom.
endpoint_idsarray of UUIDall endpointsRestricts the report to scans of these endpoints.
suite_idsarray of UUIDExpanded to the endpoints each suite covers, then merged with endpoint_ids.

scan_count in the response reports how many scans the filter matched. A value of 0 means the document will still be generated but will contain no scan data, which usually indicates the date range or the endpoint filter is wrong.

Report types

Each type carries a different section set. Every type opens with Scope and limitations, built from the per-probe attempt record: probes attempted, passed, failed, inconclusive, and how many did not reach the target.

typeContainsFor
executive_summaryScope, posture, severity distribution, composite risk, framework mappingA risk owner. No per-finding evidence.
compliance_evidenceScope, posture, severity, per-finding detail, asset inventory, framework mappingAn assessor. Excludes the composite risk score on purpose: that number is modelled, not adversarially tested, and this is the document that must not blur the two.
full_scanEverything above plus registered agents, attack paths and agentic postureSecurity engineering. The default.
custom_rangeRenders as full_scanDeprecated. It names a range selector, and the range already lives in date_from/date_to. Still accepted so historical rows and existing integrations keep working.

Inconclusive results are reported separately and are never counted as passes. A probe that ran without a confident verdict says nothing about the control.

Limits

LimitValueReason
Requests6 per minute per tokenGeneration costs far more than a read.
Reports in flight5 per tenantA caller inside the rate limit could still queue work faster than the worker drains it.
Custom range width366 daysAn unbounded range pulls an entire history into one render job.
endpoint_ids + suite_ids100 ids combinedBounds the scan lookup.

Both 429 responses carry retry_after_seconds and a Retry-After header. Back off on that value rather than retrying on a fixed interval.

Filter ids are validated as a set, not individually. One malformed id rejects the whole request instead of being dropped, because silently ignoring an id would widen the report scope past what the caller asked for. Duplicate ids are collapsed before the cap is applied, so the limit bounds distinct ids.

A body that is not a JSON object is rejected rather than treated as an empty request. A serialisation bug on the client used to produce a 202 and a default 30-day report, which spent a queue slot and a rate-limit token on a document nobody asked for. Send valid JSON or no body at all.

Poll a report

curl -sS https://penaxtra.com/api/v2/reports/$REPORT_ID \
  -H "Authorization: Bearer $TOKEN"
{
  "id": "affd91c5-6c1e-4a2f-9a10-2d4e7c8b3f52",
  "name": "Q2 2026 evidence pack",
  "type": "compliance_evidence",
  "report_format": "pdf",
  "status": "ready",
  "date_from": "2026-04-01 00:00:00+00",
  "date_to": "2026-06-30 23:59:59+00",
  "generated_at": "2026-08-04 09:12:44+00",
  "pdf_size_bytes": 98744,
  "created_at": "2026-08-04 09:12:20+00",
  "failed": false,
  "download_available": true
}
StatusMeaning
queuedAccepted, waiting for the worker.
generatingBeing rendered.
readyDocument is on disk and downloadable.
failedGeneration did not complete. Queue a new report.
expiredRetention window has passed and the document was pruned.

Poll until download_available is true or failed is true. A four-second interval is enough; most documents finish in seconds, and wide ranges in under a minute.

The failure reason is not returned. A generation failure can carry internal detail, so the API reports only that it failed; the reason is available in the console and in the audit log, both of which are access-controlled.

Download the document

curl -sS -OJ https://penaxtra.com/api/v2/reports/$REPORT_ID/download \
  -H "Authorization: Bearer $TOKEN"

The response body is the document. Headers:

Content-Type: application/pdf
Content-Disposition: attachment; filename="Q2-2026-evidence-pack.pdf"
X-Content-Type-Options: nosniff
Cache-Control: private, no-store

Content-Type is application/vnd.openxmlformats-officedocument.wordprocessingml.document for DOCX. The filename is derived from the report name and reduced to A-Za-z0-9._-, so it is safe to write to disk without further handling.

Every download is recorded as a report.downloaded audit event against the report id, whether it was taken through the API, the console, or a signed URL. Use the Audit log API to review document egress.

List reports

curl -sS https://penaxtra.com/api/v2/reports \
  -H "Authorization: Bearer $TOKEN"

Returns the 100 most recent reports for the tenant, newest first, as {"data": [...]}. This route is not paginated; use GET /api/v2/reports/{id} when you already hold an id.

Errors

StatuserrorCause
400invalid_typetype outside the allowed set
400invalid_formatformat is not pdf or docx
400invalid_rangerange is not a known preset or custom
400invalid_date_rangedate_from or date_to is not YYYY-MM-DD
400inverted_date_rangedate_to is before date_from
400date_range_too_wideCustom range exceeds 366 days
400invalid_filter_idsA filter entry is not a UUID, or the field is not an array
400too_many_filter_idsMore than 100 ids across both filter fields
400name_too_longname exceeds 200 characters
400bad_idPath id is not a UUID
400invalid_jsonBody is not valid JSON, or nests deeper than 512 levels
400invalid_json_objectBody parsed but is a list or a scalar rather than an object
413payload_too_largeBody exceeds 1 MiB
415unsupported_media_typeBody sent with a non-JSON Content-Type
403scope_missingToken lacks the scope for the route
404report_not_foundUnknown id, or the report belongs to another tenant
409report_not_readyDownload requested before generation finished. The body carries the current status.
410artifact_unavailableRow is ready but the document is no longer on disk
429rate_limitedMore than 6 generate calls in a minute
429report_queue_full5 reports already queued or generating
502enqueue_failedHand-off to the worker failed. The row is marked failed and its id is returned.

404 covers both an unknown id and a report owned by another tenant. The two cases are not distinguishable from outside, so this route cannot be used to test whether a given id exists.

Worked example

Generate a quarterly evidence pack and wait for it:

set -euo pipefail

ID=$(curl -sS -X POST https://penaxtra.com/api/v2/reports \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"type":"compliance_evidence","range":"90d","name":"Quarterly evidence"}' \
  | jq -r .id)

until [ "$(curl -sS "https://penaxtra.com/api/v2/reports/$ID" \
            -H "Authorization: Bearer $TOKEN" | jq -r .status)" = "ready" ]; do
  sleep 4
done

curl -sS -OJ "https://penaxtra.com/api/v2/reports/$ID/download" \
  -H "Authorization: Bearer $TOKEN"

In production, break the loop when failed is true and cap the total wait so a stuck job does not block the pipeline.

Related

Last reviewed: 2026-08-05. Reviewed by: Engineering. Content type: Developer documentation. Reach the maintainers: [email protected] .