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.
| Route | Scope |
|---|---|
POST /api/v2/reports | reports:write |
GET /api/v2/reports | reports:read |
GET /api/v2/reports/{id} | reports:read |
GET /api/v2/reports/{id}/download | reports: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.
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | generated from the date range | Maximum 200 characters. Control characters are stripped. |
type | string | full_scan | See the table below. |
format | string | pdf | pdf or docx. Both carry identical data. |
range | string | 30d | 7d, 30d, 90d, or custom. |
date_from | string | YYYY-MM-DD. Required when range is custom. | |
date_to | string | YYYY-MM-DD. Required when range is custom. | |
endpoint_ids | array of UUID | all endpoints | Restricts the report to scans of these endpoints. |
suite_ids | array of UUID | Expanded 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.
type | Contains | For |
|---|---|---|
executive_summary | Scope, posture, severity distribution, composite risk, framework mapping | A risk owner. No per-finding evidence. |
compliance_evidence | Scope, posture, severity, per-finding detail, asset inventory, framework mapping | An 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_scan | Everything above plus registered agents, attack paths and agentic posture | Security engineering. The default. |
custom_range | Renders as full_scan | Deprecated. 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
| Limit | Value | Reason |
|---|---|---|
| Requests | 6 per minute per token | Generation costs far more than a read. |
| Reports in flight | 5 per tenant | A caller inside the rate limit could still queue work faster than the worker drains it. |
| Custom range width | 366 days | An unbounded range pulls an entire history into one render job. |
endpoint_ids + suite_ids | 100 ids combined | Bounds 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
}
| Status | Meaning |
|---|---|
queued | Accepted, waiting for the worker. |
generating | Being rendered. |
ready | Document is on disk and downloadable. |
failed | Generation did not complete. Queue a new report. |
expired | Retention 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
| Status | error | Cause |
|---|---|---|
| 400 | invalid_type | type outside the allowed set |
| 400 | invalid_format | format is not pdf or docx |
| 400 | invalid_range | range is not a known preset or custom |
| 400 | invalid_date_range | date_from or date_to is not YYYY-MM-DD |
| 400 | inverted_date_range | date_to is before date_from |
| 400 | date_range_too_wide | Custom range exceeds 366 days |
| 400 | invalid_filter_ids | A filter entry is not a UUID, or the field is not an array |
| 400 | too_many_filter_ids | More than 100 ids across both filter fields |
| 400 | name_too_long | name exceeds 200 characters |
| 400 | bad_id | Path id is not a UUID |
| 400 | invalid_json | Body is not valid JSON, or nests deeper than 512 levels |
| 400 | invalid_json_object | Body parsed but is a list or a scalar rather than an object |
| 413 | payload_too_large | Body exceeds 1 MiB |
| 415 | unsupported_media_type | Body sent with a non-JSON Content-Type |
| 403 | scope_missing | Token lacks the scope for the route |
| 404 | report_not_found | Unknown id, or the report belongs to another tenant |
| 409 | report_not_ready | Download requested before generation finished. The body carries the current status. |
| 410 | artifact_unavailable | Row is ready but the document is no longer on disk |
| 429 | rate_limited | More than 6 generate calls in a minute |
| 429 | report_queue_full | 5 reports already queued or generating |
| 502 | enqueue_failed | Hand-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] .