Estimates API
Render, fetch, approve, and void saved estimates for a project.
GET /api/v1/estimates/{estimateId}/render
Render a saved estimate as HTML. Returns Content-Type: text/html — open directly in a browser tab or embed in a webview.
Response
Raw HTML document (not JSON). On failure, returns 404 Not Found if the estimate doesn't exist for your tenant, or 500 if rendering fails.
GET /api/v1/estimates/{estimateId}
Get a saved estimate's metadata as JSON. Returns the totals breakdown plus the project's currency code, localized tax label, and the estimate's snapshotted tax rate.
Response (200 OK)
{
"id": 45,
"projectId": 123,
"estimateNumber": "EST-2025-007",
"estimateDate": "2025-04-12T00:00:00Z",
"validDays": 30,
"validUntil": "2025-05-12T00:00:00Z",
"totalAmount": 57295.90,
"totalEquipment": 41200.00,
"totalServices": 8500.00,
"totalShipping": 1250.00,
"totalTax": 6345.90,
"taxRate": 8.250,
"snapshotedAt": "2025-04-12T15:22:00Z",
"createdAt": "2025-04-12T15:22:00Z",
"hasSnapshot": true,
"currency": "USD",
"taxLabel": "Tax"
}
taxLabel tracks the project's currency: USD returns "Tax", GBP/EUR return "VAT", CAD/AUD/INR return "GST", JPY returns "Consumption Tax". Display this verbatim — no client-side mapping needed.
Errors
404 Not Found— estimate does not exist for your tenant.
DELETE /api/v1/estimates/{estimateId}
Delete a saved estimate. Refuses to delete an approved estimate — void the approval first.
Response
204 No Content— deleted successfully.404 Not Found— estimate does not exist for your tenant.409 Conflict— estimate is approved (has a linked Contract). Void the approval first.
409 Response Body
{
"error": "estimate_approved",
"message": "Cannot delete an approved estimate. Void the approval first."
}
POST /api/v1/estimates/{estimateId}/approve
Mark a saved estimate as Approved. Creates a Contract row and flips the underlying Version's status to Contracted.
Response (200 OK)
{
"id": 88,
"versionId": 17,
"approvedAt": "2025-04-18T14:05:00Z",
"approvedByUserId": "auth0|abc123"
}
Errors
400 Bad Request— estimate cannot be approved (already approved, not the active version, etc.). Body:{ "error": "<reason>" }.401 Unauthorized— missing user context.
POST /api/v1/projects/{projectId}/approve-latest-estimate
Project-keyed approval — looks up the latest saved estimate on the project (or the explicit estimateId in the body, if supplied), then approves it the same way as /api/v1/estimates/{id}/approve. Idempotent: re-approving an already-Contracted version returns the existing Contract with alreadyApproved: true instead of erroring.
Request body (all fields optional)
{
"estimateId": 1234
}
Omit the body (or pass {}) to approve the latest saved estimate on the project's active version.
Response (200 OK)
{
"success": true,
"projectId": 42,
"estimateId": 1234,
"estimateNumber": "EST-2026-042",
"contractId": 567,
"versionId": 789,
"versionLabel": "V2",
"totalAmount": 57295.90,
"currency": "USD",
"approvedAt": "2026-05-28T18:42:00Z",
"alreadyApproved": false
}
Errors
404 Not Found— no saved estimate on the project. Body:{ "error": "no_draft_estimate", "projectId": 42, "estimateId": null, "message": "..." }.404 Not Found— explicitestimateIddoesn't exist. Body:{ "error": "estimate_not_found", ... }.400 Bad Request— explicitestimateIdbelongs to a different project. Body:{ "error": "estimate_project_mismatch", ... }.400 Bad Request— target version is Archived. Body:{ "error": "version_archived", "versionId": 789, ... }.401 Unauthorized— missing user context.
POST /api/v1/projects/{projectId}/active-version-estimate-html
Returns the rendered HTML of the latest saved estimate ON THE PROJECT'S ACTIVE VERSION (or an explicit estimateId if supplied). The HTML comes from the SavedEstimate's stored snapshot — not a fresh re-render — so the output matches what was approved at save time. If the project has no ActiveVersionId (pre-version-system project), falls back to the latest saved estimate across the whole project.
Request body (all fields optional)
{
"estimateId": 1234
}
Response (200 OK)
{
"success": true,
"projectId": 42,
"estimateId": 1234,
"estimateNumber": "EST-2026-042",
"html": "<!DOCTYPE html><html>...</html>"
}
Errors
404 Not Found— no saved estimate. Body:{ "error": "no_draft_estimate", ... }.404 Not Found— explicitestimateIddoesn't exist. Body:{ "error": "estimate_not_found", ... }.400 Bad Request— explicitestimateIdbelongs to a different project. Body:{ "error": "estimate_project_mismatch", ... }.500 Internal Server Error— render failed. Body:{ "error": "render_failed", "estimateId": 1234, "message": "..." }.
POST /api/v1/estimates/{estimateId}/void
Void an approved estimate. Deletes the linked Contract and flips the Version back to Draft. Allowed only when the Contract has zero activity — no approved Change Orders and no saved invoices.
Response
200 OK— void succeeded.422 Unprocessable Entity— Contract has live activity. Body includes counts:
{
"error": "Contract has approved change orders or saved invoices...",
"approvedChangeOrders": 1,
"invoices": 2
}
400 Bad Request— estimate is not in an approved state. Body:{ "error": "<reason>" }.401 Unauthorized— missing user context.
POST /api/v1/projects/{projectId}/void-latest-estimate
Project-keyed void — resolves the project's latest saved estimate (or the explicit estimateId in the body, if supplied) and voids the approval. Same guards as /api/v1/estimates/{id}/void. Idempotent: voiding an already-Draft estimate is a no-op returning alreadyVoided: true.
Request body (all fields optional)
{
"estimateId": 1234
}
Response (200 OK)
{
"success": true,
"projectId": 42,
"estimateId": 1234,
"estimateNumber": "EST-2026-042",
"alreadyVoided": false
}
Errors
404 Not Found— no saved estimate. Body:{ "error": "no_draft_estimate", ... }.400 Bad Request— explicitestimateIdbelongs to a different project. Body:{ "error": "estimate_project_mismatch", ... }.422 Unprocessable Entity— Contract has approved Change Orders or live invoices. Body:{ "error": "void_blocked", "approvedChangeOrders": N, "invoices": M, "message": "..." }— caller must void the downstream COs/invoices first.401 Unauthorized— missing user context.
