AuditForge API Reference¶
REST API endpoints for AuditForge engagement management. All endpoints under /auditforge/*. All gated by the x-admin-token header (the firm's shared admin token; per-user auth is an open hardening item).
Base URL on production: https://metis-demo.base2ml.com.
Authentication¶
Two paths, both supported on every endpoint:
# Legacy — platform-wide admin
x-admin-token: <ADMIN_TOKEN>
# Per-user session (Phase 8)
Authorization: Bearer <session_token>
Missing or invalid auth returns 401 {"detail": "Authentication required."}.
Login¶
POST /auditforge/auth/login
{ "email": "partner@firm.com", "password": "...", "totp_code": "...", "backup_code": "..." }
totp_code and backup_code are optional — required only when the user has TOTP MFA enrolled. Provide whichever you have. When the user has MFA enrolled but neither field is provided, the response is:
The frontend handles this by prompting for the code without re-asking for the password. On full success:
{
"token": "<opaque session token>",
"expires_at": "2026-05-09T08:30:00Z",
"user": { "id": "u-...", "email": "...", "firm_id": "...", "role": "partner", "display_name": "...", "must_change_password": false, "totp_enabled": true }
}
Send the token as Authorization: Bearer <token> on subsequent requests.
TOTP MFA (Phase 11)¶
POST /auditforge/auth/totp/enroll # session-authed; returns secret + provisioning_uri
POST /auditforge/auth/totp/confirm # body: { secret, totp_code } → activates + returns 10 backup codes ONCE
POST /auditforge/auth/totp/disable # body: { totp_code } OR { backup_code } → clears MFA
Enrollment is two-step: enroll generates a fresh secret without committing it to the user record; confirm verifies a code, activates MFA, and issues backup codes. This separation prevents in-progress enrollments that never complete from leaving phantom credentials on the account.
Logout¶
Returns {"ok": true}. Idempotent.
Resolve current caller¶
Returns:
{
"is_admin": true,
"user_id": "u-..." or null,
"firm_id": "firm-..." or null,
"email": "..." or null
}
user_id is null for admin-token callers. firm_id is null for platform admins (admin-token or admin-role users).
Change password¶
POST /auditforge/auth/change-password
Authorization: Bearer <token>
{ "current_password": "...", "new_password": "..." }
Verifies the current password, applies the new one, revokes all other sessions for this user.
User CRUD (admin-only)¶
POST /auditforge/user
GET /auditforge/user[?firm_id=...]
DELETE /auditforge/user/{user_id}
POST /auditforge/user/{user_id}/role # Phase 15 — change role; revokes sessions
POST /auditforge/user/{user_id}/clear-mfa # Phase 16 — clear TOTP enrollment
POST /auditforge/user/{user_id}/clear-lockout # Phase 16 — clear brute-force lockout
POST /auditforge/user/{user_id}/reset-password # Phase 16 — admin-issue temp password; revokes sessions
Create-user body:
{
"email": "partner@firm.com",
"firm_id": "firm-...",
"role": "partner", // admin | partner | associate
"password": "...", // initial; min 8 chars
"display_name": "Pat Partner",
"must_change_password": true
}
Role-update body: { "role": "associate" }. Returns {"ok": true, "id": "u-...", "new_role": "...", "sessions_revoked": N}. The user's existing sessions are revoked so role changes take effect on next login.
Roles (Phase 15)¶
| Role | Scope | Mutate ops | User/firm CRUD |
|---|---|---|---|
admin |
Platform-wide | ✅ | ✅ |
partner |
Their firm | ✅ | — |
associate |
Their firm (read-only) | — | — |
Mutating endpoints (POST /engagement, run, finding accept/reject/refine/edit, investigate-further, recompute clusters, intake extract) return 403 when called by an associate. Read endpoints work for all roles within their firm.
Multi-reviewer collaboration (Phase 28b)¶
POST /auditforge/engagement/{id}/assignment # body: { "user_id": "u-..." | null }
GET /auditforge/finding/{id}/comments?engagement_id=...
POST /auditforge/finding/{id}/comments?engagement_id=... # body: { "body": "text" }
DELETE /auditforge/comment/{id}?engagement_id=...
Engagement assignment surfaces in the response shape as assigned_user_id (or null). Cross-firm assignment blocked with 409 unless caller is platform admin. Comments are 1–4000 chars; author or admin can delete. Comments stored in the engagement's isolated S3 bucket under comments.json. Internal-only — not rendered in deliverables. Same auth + role gating: associates 403.
Self-serve corpus onboarding (Phase 25–27)¶
POST /auditforge/engagement/{id}/corpus/upload # multipart, one file per request
DELETE /auditforge/engagement/{id}/corpus/file/{name}
POST /auditforge/engagement/{id}/corpus/ingest # 202, background task
GET /auditforge/engagement/{id}/corpus/stream # SSE
Allowed extensions: .txt .md .pdf .docx .csv .xlsx .png .jpg .jpeg .tiff .eml .mbox. Max 50 MB per file, 500 files per engagement. Duplicate filenames return 409. Refused when engagement is frozen or already ingested. Once corpus.status == "ingested", engagement.client_id is auto-bound to engagement.id, ready for POST /run. SSE events: ingest_start, ingest_stage (loading|chunking|finalizing), ingest_complete, ingest_failed. Same auth + role gating as other mutations: associates 403 (Phase 15).
Engagement template library (Phase 23)¶
GET /auditforge/template[?firm_id=...]
GET /auditforge/template/{id}
POST /auditforge/template # body: firm_id, name, [from_engagement_id] OR [intake fields]
PUT /auditforge/template/{id}
DELETE /auditforge/template/{id}
POST /auditforge/template/{id}/instantiate # body: { client_name, budget_cents? }
Templates capture an engagement's intake config + archetype + budget for reuse. from_engagement_id snapshots an existing engagement; instantiating creates a new engagement pre-filled. Firm-scoped: non-admin callers only see their firm's templates. Admin/partner can mutate; associates 403 (Phase 15).
Cluster diff over time (Phase 22)¶
Compares the latest cluster snapshot against the previous one. Returns { summary: {added, removed, grew, shrank, unchanged}, entries: [...] } where each entry has change classification + member-overlap percentage. Each recompute automatically saves the prior snapshot before overwriting.
Audit log signed URL (Phase 24)¶
GET /auditforge/engagement/{id}/audit-log
?format=jsonl|json
&delivery=stream|signed_url # default stream
&expires_in=600 # 60..3600 seconds
delivery=signed_url materializes the audit log to auditforge/exports/<id>-<timestamp>.<ext> and returns { signed_url, size_bytes, event_count, expires_in, expires_at, ... }. Useful for very large logs where streaming through FastAPI is wasteful. Empty audit log → signed_url: null, empty: true.
Engagement archive (Phase 21)¶
POST /auditforge/engagement/{id}/archive # admin only; engagement must be delivered
POST /auditforge/engagement/{id}/unarchive # admin only; restores to delivered
GET /auditforge/engagement?include_archived=true # opt-in to see archived
Archive is reversible and non-destructive — findings, audit logs, deliverables stay. Default list excludes archived; pass include_archived=true to see them. The unarchive transition does not unfreeze; call /unfreeze separately if you need to edit. Returns { "ok", "engagement_id", "status", "already_archived"|"was_archived" }.
Engagement freeze (Phase 20)¶
POST /auditforge/engagement/{id}/deliver # partner+: freeze
POST /auditforge/engagement/{id}/unfreeze # admin only: unfreeze
deliver transitions to delivered, sets delivered_at, freezes finding mutations (intake, run, finding accept/reject/refine/edit, investigate-further, bulk-action all return 423 LOCKED). Idempotent — second call returns 200 with already_delivered: true. unfreeze transitions back to findings_review; admin role only. is_frozen: bool is now exposed on the engagement response shape.
Firm logo upload (Phase 19)¶
POST /auditforge/firm/{firm_id}/logo # multipart/form-data with `file` field
DELETE /auditforge/firm/{firm_id}/logo
Allowed content types: image/png|jpeg|svg+xml|webp. Max size 200 KB. The image is base64-encoded and stored as a data URL on the firm's logo_url field — no external CDN required, embeds cleanly in DOCX export. POST returns { "ok", "firm_id", "logo_url", "size_bytes", "content_type" }. DELETE returns { "ok", "logo_url": "" }. Same auth + role gating as other firm mutations: admin token or admin/partner session role; associates 403.
Bulk finding actions (Phase 17)¶
POST /auditforge/findings/bulk-action?engagement_id=<id>
Content-Type: application/json
{
"finding_ids": ["f-abc", "f-def", ...], // ≥1, ≤200
"action": "accepted", // accepted | rejected | refined
"auditor_notes": "" // optional, applied to all
}
Returns { "ok": true, "total": N, "succeeded": M, "failed": K, "results": [...] }. Each per-id result is { "id": "...", "ok": true|false, "status": "...", "error": "..." }. The endpoint returns 200 on partial failure (e.g., one stale ID); per-id results let the UI surface what failed without aborting the batch. Same auth as singular finding actions: associates blocked by Phase 15.
Admin recovery (Phase 16)¶
Three admin-only endpoints close the SOC 2 readiness gap where a user who loses their TOTP device or trips brute-force lockout has no recovery path.
| Endpoint | Effect |
|---|---|
POST /user/{id}/clear-mfa |
{ "ok", "id", "was_enabled" } — clears TOTP so user can re-enroll |
POST /user/{id}/clear-lockout |
{ "ok", "id", "had_record" } — lifts brute-force lockout |
POST /user/{id}/reset-password body { "new_password": "..." } |
{ "ok", "id", "sessions_revoked" } — issues temp password (≥12 chars), forces must_change_password, revokes sessions |
Each call writes a structured warning log line (auditforge_admin_clear_mfa, …_clear_lockout, …_reset_password) for compliance audit trails. See 29-admin-recovery.md for operator workflow.
Engagements¶
Create engagement¶
Body:
{
"firm_id": "firm-abc123",
"client_name": "Northstar Defense Inc.",
"archetype": "remediation_pipeline",
"budget_cents": 1500
}
Returns: 201 engagement object (see GET below).
archetype is one of: remediation_pipeline, capability_leverage, premium_defensibility, continuous_monitoring. budget_cents is the per-engagement hard cap on compute spend.
List engagements¶
Pagination (Phase 13): limit defaults to 100 (max 500); offset defaults to 0. Sort: updated_at desc.
Returns:
Get engagement¶
Returns:
{
"id": "eng-6715f196fa40",
"firm_id": "firm-abc",
"client_id": "auditforge_test_corpus",
"client_name": "Northstar Defense Inc.",
"archetype": "remediation_pipeline",
"status": "findings_review",
"intake": { "domain": "...", "audit_purpose": "...", "frameworks": [...], "focus_areas": [...], "materiality": null, "doc_hierarchy": {}, "known_concerns": [...] },
"cost": { "budget_cents": 800, "spent_cents": 222.6, "by_stage": {"profile": 1.1, ...}, "by_model": {...}, "last_updated_at": "..." },
"findings": { "total": 92, "pending": 75, "accepted": 0, "rejected": 0, "refined": 17, "by_severity": {"critical": 12, "high": 46, "medium": 5, "low": 0} },
"is_running": false,
"created_at": "...",
"updated_at": "...",
"started_at": "...",
"completed_at": "...",
"delivered_at": null,
"failure_reason": null
}
Delete engagement¶
Returns: 200 {"ok": true}. Removes the engagement record only — S3 cleanup of findings / deliverables happens separately (today: not at all; cleanup is an open hardening item).
Set intake¶
Body:
{
"domain": "defense contractor preparing CMMC L2 pre-assessment",
"audit_purpose": "CMMC L2 pre-assessment to identify compliance gaps",
"frameworks": ["NIST SP 800-171", "DFARS 252.204-7012", "CMMC L2"],
"focus_areas": ["cybersecurity", "subcontractor compliance"],
"materiality": null,
"doc_hierarchy": {},
"known_concerns": ["subcontract flow-down may be incomplete"]
}
Returns: Updated engagement object.
Run audit¶
Body:
{
"client_id": "auditforge_test_corpus",
"max_iterations": 1,
"max_followup_rounds": 0,
"enable_adversarial_verification": true,
"adversarial_severity_threshold": "medium",
"max_questions_per_iteration": 20
}
Returns: 202 {"ok": true, "engagement_id": "...", "status": "queued"}. Background task spawned; poll /stream for progress events or /engagement/{id} for status.
client_id is the Metis tenant whose corpus to audit — must be ingested already.
max_questions_per_iteration caps the investigate stage to top-N priority-sorted questions; useful for cost-bounded validation.
Stream progress¶
Server-Sent Events stream of pipeline progress. Frame format:
data: {"type": "stage_start", "stage": "catalog", "iteration": 0}
data: {"type": "stage_complete", "stage": "catalog", "concepts": 44, "doc_pairs": 16, ...}
Closes when run_complete event fires or queue idle. The frontend's useEngagementStream hook (in frontend/src/hooks/useEngagementStream.ts) is a fetch-based SSE consumer — EventSource can't carry custom headers, so we use fetch() + ReadableStream.
Event types: stage_start, stage_complete, questions_capped, converged, aborted, run_complete, run_failed, plus per-question / per-finding intermediate events.
List findings¶
Returns:
Each finding includes is_canonical, merged_finding_ids, corroboration_score, filter_status, filter_overridden_by. Frontend filters to is_canonical || not in merged_ids for display.
Audit log export (Phase 14)¶
GET /auditforge/engagement/{engagement_id}/audit-log?format=jsonl
GET /auditforge/engagement/{engagement_id}/audit-log?format=json
Returns the full per-engagement audit log — every LLM call (model, tokens, cost, latency, stage, downshift hint, timestamp). jsonl (default) returns a downloadable newline-delimited stream; json returns {"engagement_id": "...", "shard_count": N, "event_count": M, "events": [...]}.
Honors per-engagement firm scoping (Phase 9) — non-admin session callers get 404 on cross-firm requests.
Get deliverable¶
Returns:
| Format | Response |
|---|---|
json |
The full structured deliverable object |
markdown |
{"markdown": "..."} |
methodology |
{"text": "..."} (the methodology section text) |
docx |
Binary DOCX bytes with Content-Disposition: attachment |
html |
Print-optimized HTML page with firm branding (cover headings in firm primary color, @page rules for margins / footers / pagination, blockquote styling for evidence quotes). Open in a new tab and use browser Print → Save as PDF to produce a client-deliverable PDF. |
Serves the polished CLI-generated artifact from S3 cache when present (with the LLM-written executive summary). Falls back to on-the-fly regeneration with placeholder summary + canonical-filter when no cache.
Findings¶
Cross-engagement portfolio clusters (Phase 10)¶
GET /auditforge/findings/portfolio-clusters?firm_id=...&primitive=...&severity=...&canonical_only=true
POST /auditforge/findings/portfolio-clusters/recompute?<same params>
GET returns the cached result (24-hour TTL) or {"cached": false, "result": null} if no cache exists. POST fires an Opus call (~$0.30, hard-capped at $0.80), computes fresh clusters, caches, returns. For non-admin session callers, firm_id is overridden to the caller's firm.
Returns:
{
"filter_key": "<sha256 prefix>",
"cached": true,
"result": {
"generated_at": "2026-05-09T04:15:00Z",
"filters": {...},
"total_findings_considered": 47,
"total_engagements_scanned": 7,
"cost_cents": 28.4,
"succeeded": true,
"clusters": [
{
"id": "pc-001",
"theme": "...",
"prevalence_summary": "found in 4 of 7 engagements (57%)",
"member_engagement_ids": [...],
"member_finding_ids": [...],
"severity_distribution": { "critical": 2, "high": 5, ... },
"representative_quotes": [...],
"suggested_standard_remediation": "...",
"confidence": 0.85
},
...
]
}
}
Cross-engagement search (Phase 5)¶
GET /auditforge/findings/search?q=...&primitive=...&severity=...&status=...&firm_id=...&canonical_only=true&limit=200
Searches across all engagements the admin token has access to. Substring match on q runs against description + root_cause + auditor_notes.
Returns:
{
"query": "NIST SP 800-171 r3",
"filters": {...},
"matches": [
{ "engagement_id": "...", "engagement_client_name": "...", "firm_id": "...", "finding": {...} }
],
"count": 47,
"limit": 200
}
canonical_only=true (default) hides raw findings that have been swallowed by a canonical (Stage E.5 consolidation). Set to false to see the raw findings too.
Accept¶
Body:
Returns: Updated finding object.
Reject¶
Body:
Refine¶
Body:
status defaults to refined; can be set to any of pending / accepted / rejected / refined for free-form transitions.
Edit (Phase 3)¶
Body (all fields optional, only provided fields apply):
{
"description": "Rewrote description for client-facing language",
"root_cause": "...",
"remediation_scope": "...",
"remediation_effort_hours": 12,
"severity": "high"
}
Returns: Updated finding. Status flips to refined and an [EDITED <fields>] line is prepended to auditor_notes for audit-trail purposes.
Investigate further (Phase 3)¶
Body:
{
"steering_text": "Look harder at audit-rights flow-down across both subcontracts",
"primitive": "flow_down_check",
"budget_cents": 1500,
"max_questions": 20
}
All fields optional. steering_text defaults to the finding's description; primitive defaults to the finding's primitive.
Returns: 200 {"ok": true, "engagement_id": "...", "parent_finding_id": "...", "primitive": "...", "status": "queued"}. Background task runs a focused mini-audit; new findings persist to the same engagement.
Requires the engagement to have intake set and client_id recorded (i.e., a prior POST /run has happened).
Firms (Phase 2.5 white-label)¶
List firms¶
Returns:
Create firm¶
Body:
{
"display_name": "Acme Audit Partners, LLP",
"short_name": "Acme",
"tagline": "Defense compliance · Pittsburgh",
"logo_url": "https://acme-audit.com/logo.png",
"primary_color": "#1a3a52",
"accent_color": "#3a7a9c",
"methodology_disclaimer": "This audit was conducted by Acme using AI-assisted document review tooling...",
"footer_text": "© 2026 Acme · Privileged & Confidential",
"confidentiality_notice": "CONFIDENTIAL — prepared for [Client] under MSA dated 2026-04-15",
"default_archetype": "remediation_pipeline",
"default_budget_cents": 1500
}
Returns: 201 firm object.
Get firm¶
Update firm¶
Body: Partial update — any subset of the create-body fields. Only sent fields apply.
Delete firm¶
Returns: 200 {"ok": true, "id": "firm-..."}. Engagements with this firm_id continue to render with default branding.
Intake helper (Phase 4)¶
Extract intake from description¶
Body:
{
"description": "Free-form paragraph describing the engagement in 2-5 sentences. Industry, frameworks in scope, known concerns. Minimum 30 chars.",
"client_name": "Northstar Defense Inc.",
"firm_id": "firm-abc"
}
Returns:
{
"domain": "...",
"audit_purpose": "...",
"frameworks": [...],
"focus_areas": [...],
"known_concerns": [...],
"suggested_archetype": "remediation_pipeline",
"suggested_client_name": "",
"cost_cents": 4.2
}
One Sonnet-class LLM call (~$0.05) capped at $0.20 per extract via an isolated CostBudget. Returns 422 if description < 30 chars; 502 if LLM call fails.
Error codes¶
| Code | When |
|---|---|
| 401 | Missing or invalid x-admin-token (or wrong password / wrong MFA code on /auth/login) |
| 423 | Account locked due to too many failed login attempts (Phase 12); response body includes locked_until ISO timestamp |
| 404 | Engagement / firm / finding not found |
| 409 | Run already in progress on this engagement |
| 422 | Validation error — bad enum, missing required field, intake required first |
| 502 | LLM call failed (intake extract) |
Open hardening items¶
- Per-user authentication (replaces shared admin token) — needed before SOC 2
- Pagination on
GET /engagementandGET /findings— today returns the full list - Rate-limiting per token — today uncapped
- Webhook on run-complete — today partner has to poll or watch SSE
See 00-product-overview.md for the broader roadmap.