Appearance
Reports
Aggregate audit metrics, export project state to PDF/CSV/JSON, and share the result over email, Slack, or a signed link. Reports work against the project's current findings, verifications, and regressions — there is no separate scheduling primitive.
Base URL: https://app.krafter.dev/api/v1
Report Summary
Retrieve aggregated metrics for a project: a 7-day risk burn-down series, the verification pass rate, the regression rate, and the count of open findings. The summary is computed live from the project's current data.
GET /orgs/:org_id/projects/:project_id/audit/reports/summaryRequired scope: audit:read
Date filtering is not yet wired up
The endpoint accepts date_from and date_to query parameters but does not currently apply them to the underlying counts — the metrics always reflect the project's current state. The risk burn-down series is always the last 7 days.
Example Request
bash
curl "https://app.krafter.dev/api/v1/orgs/:org_id/projects/:project_id/audit/reports/summary" \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": {
"risk_burn_down": [
{ "date": "2025-06-04", "open_risk_score": 320 },
{ "date": "2025-06-05", "open_risk_score": 305 },
{ "date": "2025-06-06", "open_risk_score": 290 },
{ "date": "2025-06-07", "open_risk_score": 260 },
{ "date": "2025-06-08", "open_risk_score": 240 },
{ "date": "2025-06-09", "open_risk_score": 220 },
{ "date": "2025-06-10", "open_risk_score": 210 }
],
"verification_pass_rate": 0.84,
"regression_rate": 0.12,
"open_findings": 11
},
"meta": {
"request_id": "Z-hrvhkiE1oqzTTTXmd"
},
"error": null
}risk_burn_downis a fixed 7-element series (today and the previous 6 days). Each element is{date, open_risk_score}whereopen_risk_scoreis the sum ofpriority_scoreacross findings in non-terminal statuses (new,triaged,in_progress,ready_verify).verification_pass_rateis a0..1ratio of verifications withresult: "passed"over total verifications. Returns0when there are no verifications.regression_rateis a0..1ratio ofopenregressions over findings in terminal statuses (resolved,dismissed). Returns0when nothing has been resolved yet.open_findingsis the count of findings in non-terminal statuses (new,triaged,in_progress,ready_verify).
Export Report
Queue an export of the current report summary as a JSON, CSV, or PDF artefact. The export is processed asynchronously by the Report Export Worker and consumes one unit of the team's AI quota.
POST /orgs/:org_id/projects/:project_id/audit/reports/exportRequired scope: audit:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
format | string | No | One of pdf, csv, json. Defaults to pdf when omitted. |
period_start | string | No | Start of the snapshot window (ISO 8601 date, e.g. 2025-06-04). Defaults to 6 days ago. |
period_end | string | No | End of the snapshot window (ISO 8601 date, e.g. 2025-06-10). Defaults to today. |
PDF export currently produces HTML
The pdf format option creates a queued export, but the worker currently emits an HTML artefact (Content-Type: text/html) rather than a binary PDF. Treat the pdf value as "human-readable export" until the binary renderer is wired up.
Example Request
bash
curl -X POST https://app.krafter.dev/api/v1/orgs/:org_id/projects/:project_id/audit/reports/export \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"format": "json",
"period_start": "2025-06-01",
"period_end": "2025-06-10"
}'Example Response
json
// 202 Accepted
{
"data": {
"id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"org_id": "11111111-1111-1111-1111-111111111111",
"project_id": "production-website",
"period_start": "2025-06-01",
"period_end": "2025-06-10",
"format": "json",
"status": "queued",
"snapshot": {
"risk_burn_down": [],
"verification_pass_rate": 0.84,
"regression_rate": 0.12,
"open_findings": 11
},
"exported_at": null,
"shared_at": null,
"created_at": "2025-06-12T15:00:00Z",
"updated_at": "2025-06-12T15:00:00Z"
},
"meta": {
"request_id": "aa-iswiljF2praUUUYne"
},
"error": null
}status transitions through the queued, exporting, exported, shared, failed lifecycle as the worker progresses. snapshot is the report summary captured at export time.
Error Responses
json
// 422 Unprocessable Entity — invalid format, invalid date, etc.
{
"data": null,
"meta": {
"request_id": "aa-iswiljF2praUUUYne"
},
"error": {
"code": "invalid_params"
}
}json
// 429 Too Many Requests — AI quota exhausted for this team
{
"data": null,
"meta": {
"request_id": "aa-iswiljF2praUUUYne"
},
"error": {
"code": "quota_exceeded"
}
}Share Report
Share the project's most recent report over email, Slack, or a signed link. If no report exists yet, an empty placeholder JSON report is created so the share can succeed.
POST /orgs/:org_id/projects/:project_id/audit/reports/shareRequired scope: audit:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | Yes | Delivery channel. One of email, slack, link. |
report_id | string | No | The report to share. Defaults to the project's most recent one. |
recipients | string[] | For email | Email addresses when channel: "email" — at least one, at most ten. Ignored on other channels. |
webhook_url | string | No | Slack incoming-webhook URL when channel: "slack". URL is SSRF-validated. Ignored otherwise. |
Pass report_id when it matters which report goes out
Report timestamps have one-second resolution, so two reports exported in the same second cannot be told apart by "most recent" — and export-then-share is a single round trip that lands well inside a second. Without report_id the fallback is stable (repeated calls share the same row) but it is not guaranteed to be the newest of a tied pair, and the failure is silent: the recipient reads an older report's numbers.
Export Report returns the report id. Pass it here.
A report_id that is not a report of this team and this project answers 404 not_found.
Rate limit
Sharing uses the strict bucket — 30 requests / 60 seconds per team, shared with /scans/run. It sends platform mail to addresses you choose, so it is not sized like a read. Exceeding it returns 429 Too Many Requests with retry-after and x-ratelimit-* headers and a non-enveloped body, exactly as Scans → Rate limit describes.
Example Request (link)
bash
curl -X POST https://app.krafter.dev/api/v1/orgs/:org_id/projects/:project_id/audit/reports/share \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"channel": "link"
}'Example Request (email)
bash
curl -X POST https://app.krafter.dev/api/v1/orgs/:org_id/projects/:project_id/audit/reports/share \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"recipients": ["security@example.com", "ops@example.com"]
}'Example Request (slack)
bash
curl -X POST https://app.krafter.dev/api/v1/orgs/:org_id/projects/:project_id/audit/reports/share \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"channel": "slack",
"webhook_url": "https://hooks.slack.com/services/T0000/B0000/xxxxxxxx"
}'Example Response
json
{
"data": {
"id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"channel": "email",
"recipients": ["security@example.com", "not-a-mailbox"],
"accepted": ["security@example.com"],
"rejected": ["not-a-mailbox"],
"shared_at": "2025-06-12T15:00:00Z",
"share_url": "https://app.krafter.dev/insights/audit/reports/shared/SFMyNTY.g2gDdAAAAAQ..."
},
"meta": {
"request_id": "Y-gqugjhD0npySSSWlc"
},
"error": null
}accepted and rejected split the email recipients into the ones the platform queued a message for and the ones it refused — a malformed address is the usual reason. A share with every recipient rejected still returns 200: the report is shared and its link is live; it is the mail that did not go out, and the response says so instead of hiding it. Both lists are empty on the link and slack channels.
share_url is a Phoenix-signed token URL pointing at the public viewer, and it is returned for every channel — the email body and the Slack message carry the same URL. It is valid for 48 hours and stops working the moment the report is unshared (see Revoke Share). It is never the storage object's own address: following it mints a 5-minute presigned download at the moment of the click.
Slack webhook delivery is best-effort
Slack delivery posts to webhook_url with a 10s HTTP timeout and a SSRF safety check. Failures are logged but do not surface in the API response — the call still returns success as long as the report row was updated.
Error Responses
json
// 422 Unprocessable Entity — channel not in {email, slack, link}
{
"data": null,
"meta": {
"request_id": "Y-gqugjhD0npySSSWlc"
},
"error": {
"code": "invalid_channel"
}
}json
// 422 Unprocessable Entity — channel: email with no recipients, or more than ten
{
"data": null,
"meta": {
"request_id": "Y-gqugjhD0npySSSWlc"
},
"error": {
"code": "invalid_params"
}
}json
// 422 Unprocessable Entity — report row update failed
{
"data": null,
"meta": {
"request_id": "Y-gqugjhD0npySSSWlc"
},
"error": {
"code": "request_failed"
}
}Revoke Share
Withdraw every outstanding share link for the project. Share tokens only resolve while the report is still in shared status, so this kills links already sitting in somebody's inbox — the answer to a leaked link that an expiring presigned URL cannot give.
DELETE /orgs/:org_id/projects/:project_id/audit/reports/shareRequired scope: audit:write
Sharing does not unshare what came before it, so a project can have more than one report in shared status. This endpoint revokes all of them; revoked_count says how many.
Example Request
bash
curl -X DELETE https://app.krafter.dev/api/v1/orgs/:org_id/projects/:project_id/audit/reports/share \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": {
"status": "exported",
"revoked_count": 2,
"ids": [
"9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"1f2e3d4c-5b6a-7988-9a0b-1c2d3e4f5a6b"
],
"id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d"
},
"meta": {
"request_id": "Z-hrvvkiE1oqzTTTXmd"
},
"error": null
}Error Response
json
// 404 Not Found — nothing in this project is currently shared
{
"data": null,
"meta": {
"request_id": "Z-hrvvkiE1oqzTTTXmd"
},
"error": {
"code": "not_found"
}
}