{"openapi":"3.0.0","info":{"title":"Handoff Enterprise API","version":"1.0.0","description":"Enterprise API for Handoff integrations.\n\n## Authentication\n\nThis API uses OAuth 2.0 Client Credentials flow for authentication.\n\n1. Obtain an access token from `POST /v1/oauth/token` using your client credentials\n2. Include the token in the `Authorization` header: `Bearer <token>`\n\n## Rate Limiting\n\nAPI requests are rate limited per client. Rate limit headers are included in responses:\n- `X-RateLimit-Limit`: Maximum requests per window\n- `X-RateLimit-Remaining`: Remaining requests in current window\n- `X-RateLimit-Reset`: Time when the rate limit resets (Unix timestamp)","contact":{"name":"Handoff Support","url":"https://handoff.ai"}},"servers":[{"url":"https://api.handoff.ai","description":"Production"}],"tags":[{"name":"OAuth","description":"OAuth 2.0 authentication endpoints"},{"name":"Files","description":"File upload and management endpoints"},{"name":"Estimates","description":"Estimate generation endpoints and webhook notifications"},{"name":"Webhooks","description":"Webhook endpoints"},{"name":"Health","description":"Health check endpoints"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"JWT access token obtained from the OAuth token endpoint"}},"schemas":{"HealthCheckResponse":{"type":"object","properties":{"status":{"type":"string","example":"OK"},"service":{"type":"string","example":"enterprise-api"},"latestVersion":{"type":"string","example":"v1"},"timestamp":{"type":"string","format":"date-time","example":"2024-01-15T10:30:00.000Z"},"traceId":{"type":"string","example":"abc123def456"}},"required":["status","service","latestVersion","timestamp","traceId"]},"OAuthTokenResponse":{"type":"object","properties":{"access_token":{"type":"string","description":"JWT access token","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"token_type":{"type":"string","enum":["Bearer"],"example":"Bearer"},"expires_in":{"type":"integer","description":"Token lifetime in seconds","example":3600},"scope":{"type":"string","description":"Space-separated list of granted scopes","example":"estimates:read estimates:write materials:create blueprints:create presets:manage files:manage data:delete webhooks:manage"}},"required":["access_token","token_type","expires_in","scope"]},"ErrorDetails":{"type":"object","properties":{"code":{"type":"string","enum":["UNAUTHORIZED","FORBIDDEN","INVALID_REQUEST","VALIDATION_ERROR","RESOURCE_NOT_FOUND","CONFLICT","RATE_LIMIT_EXCEEDED","INTERNAL_ERROR","SERVICE_UNAVAILABLE"],"description":"Error code. Possible values: UNAUTHORIZED, FORBIDDEN, INVALID_REQUEST, VALIDATION_ERROR, RESOURCE_NOT_FOUND, CONFLICT, RATE_LIMIT_EXCEEDED, INTERNAL_ERROR, SERVICE_UNAVAILABLE","example":"INVALID_REQUEST"},"message":{"type":"string","description":"Human-readable error message","example":"Invalid request parameters"},"traceId":{"type":"string","description":"Trace ID for debugging (include in support requests)","example":"abc123def456"},"details":{"type":"object","additionalProperties":{"type":"string"},"description":"Additional error details (field-level validation errors)","example":{"client_id":"must be a valid UUID"}},"retryAfter":{"type":"integer","description":"Seconds to wait before retrying (only for rate limit errors)","example":60}},"required":["code","message"]},"Error":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/ErrorDetails"}},"required":["error"]},"OAuthTokenRequest":{"type":"object","properties":{"grant_type":{"type":"string","enum":["client_credentials"],"description":"Must be \"client_credentials\"","example":"client_credentials"},"client_id":{"type":"string","format":"uuid","description":"Your OAuth client ID","example":"550e8400-e29b-41d4-a716-446655440000"},"client_secret":{"type":"string","minLength":1,"description":"Your OAuth client secret","example":"your-client-secret"},"scope":{"type":"string","description":"Space-separated list of requested scopes (optional, defaults to all granted scopes). Available scopes: estimates:read, estimates:write, materials:create, blueprints:create, presets:manage, files:manage, data:delete, webhooks:manage","example":"estimates:read estimates:write materials:create blueprints:create presets:manage files:manage data:delete webhooks:manage"}},"required":["grant_type","client_id","client_secret"]},"UploadUrlItem":{"type":"object","properties":{"fileId":{"type":"string","format":"uuid","description":"Unique identifier for the file","example":"550e8400-e29b-41d4-a716-446655440000"},"filename":{"type":"string","description":"Original filename","example":"floor_plan.pdf"},"uploadUrl":{"type":"string","format":"uri","description":"Presigned S3 URL for uploading the file via PUT request","example":"https://bucket.s3.amazonaws.com/enterprise/..."},"expiresAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the upload URL expires","example":"2024-01-15T10:30:00.000Z"},"uploadMethod":{"type":"string","enum":["PUT"],"description":"HTTP method to use for upload","example":"PUT"}},"required":["fileId","filename","uploadUrl","expiresAt","uploadMethod"]},"UploadUrlsResponse":{"type":"object","properties":{"uploadUrls":{"type":"array","items":{"$ref":"#/components/schemas/UploadUrlItem"},"description":"Array of presigned upload URLs"},"expiresInSeconds":{"type":"integer","description":"Seconds until the upload URLs expire","example":300}},"required":["uploadUrls","expiresInSeconds"]},"FileObject":{"type":"object","properties":{"filename":{"type":"string","minLength":1,"maxLength":255,"description":"Name of the file. Supported formats: PDF, DOC, DOCX, CSV, TXT, XLS, XLSX (documents), and image formats: PNG, JPG, JPEG, JPE, JFIF, JIF, GIF, WebP, BMP, TIFF, TIF, SVG, ICO, HEIC, HEIF, AVIF, APNG","example":"floor_plan.pdf"},"contentType":{"type":"string","description":"MIME type of the file. Supported document types: application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, text/csv, text/plain, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. All image/* MIME types are also supported (e.g., image/png, image/jpeg, image/heic, etc.)","example":"application/pdf"},"sizeBytes":{"type":"integer","minimum":0,"exclusiveMinimum":true,"maximum":1073741824,"description":"Exact file size in bytes (max 1GB). S3 will reject uploads that don't match this size.","example":1048576}},"required":["filename","contentType","sizeBytes"]},"UploadUrlsRequest":{"type":"object","properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/FileObject"},"minItems":1,"maxItems":100,"description":"Array of files to upload (1-100 files per request)"},"expirationMinutes":{"type":"integer","minimum":1,"maximum":15,"default":5,"description":"URL expiration time in minutes (default: 5, range: 1-15)","example":5}},"required":["files"]},"EstimateResponse":{"type":"object","properties":{"estimateJobId":{"type":"string","format":"uuid","description":"Unique identifier for the estimate job","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"status":{"type":"string","enum":["PENDING","EXTRACTING","LABELING","AGENTIC_TAKEOFF","INGESTING","PROCESSING","HUMAN_REVIEW","COMPLETED","FAILED","CANCELED"],"description":"Current job status. Possible values: PENDING, EXTRACTING, LABELING, AGENTIC_TAKEOFF, INGESTING, PROCESSING, HUMAN_REVIEW, COMPLETED, FAILED, CANCELED","example":"PROCESSING"},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of job creation","example":"2025-12-05T14:30:00Z"},"estimatedCompletionTime":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of expected completion","example":"2025-12-05T14:31:00Z"},"metadata":{"type":"object","additionalProperties":{"anyOf":[{"type":"string","maxLength":256},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}]},"description":"Custom metadata provided during job creation","example":{"includeMEP":true,"source":"takeoff-api","projectId":"proj-123"}}},"required":["estimateJobId","status","createdAt","estimatedCompletionTime"]},"EstimateRequest":{"type":"object","properties":{"type":{"type":"string","enum":["MATERIAL_LIST","BLUEPRINT"],"description":"Type of estimate request: 'MATERIAL_LIST' for material lists or 'BLUEPRINT' for blueprints","example":"MATERIAL_LIST"},"fileIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"List of file IDs to generate an estimate from. Required when type is 'BLUEPRINT'. Optional when type is 'MATERIAL_LIST' (can use 'prompt' instead or both)","example":["f1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c","f2b3c4d5-e6f7-4a8b-9c0d-1e2f3a4b5c6d"]},"address":{"type":"string","maxLength":200,"description":"Address or ZIP code for regional availability and pricing","example":"Austin, TX 78701"},"prompt":{"type":"string","maxLength":10000,"description":"Special instructions and scope of work for the estimate (e.g., 'Create an estimate for framing and painting.')","example":"Create an estimate for framing and painting."},"callbackUrl":{"type":"string","format":"uri","description":"Webhook URL for job completion notification","example":"https://partners.example.com/webhooks/estimate"},"externalId":{"type":"string","maxLength":200,"description":"Partner's internal estimate ID (e.g., associate-facing ID)","example":"EST-12345"},"storeReference":{"type":"string","maxLength":200,"description":"Store, location, or region identifier for applying store-level AI presets","example":"STORE-NYC-001"},"customerReference":{"type":"string","maxLength":200,"description":"Partner's internal reference/order number for tracking specific customers and applying customer-level AI presets","example":"CUST-ORD-789"},"takeoffProduct":{"type":"string","enum":["WINDOWS_DOORS"],"description":"Purchased takeoff product. Controls product-specific presets and commercial behavior. Omit when directly restricting trades without purchasing a named product. Only valid when type is 'BLUEPRINT'","example":"WINDOWS_DOORS"},"takeoffTrades":{"type":"array","items":{"type":"string","enum":["APPLIANCES","CABINETS","CONCRETE","COUNTERTOPS","DEMOLITION","DOORS","DRYWALL","ELECTRICAL","EXCAVATION","FLOORING","FRAMING","HVAC","INSULATION","LANDSCAPING","PAINTING","PLUMBING","ROOFING","SIDING","SPECIALTIES","TILE","TRIM","WINDOWS"]},"minItems":1,"description":"Allowlist of trades that may appear in the takeoff. When combined with takeoffProduct, it may only narrow that product's included trades. Omit for the product's full scope or an unrestricted takeoff. Only valid when type is 'BLUEPRINT'","example":["WINDOWS","DOORS"]},"metadata":{"type":"object","additionalProperties":{"anyOf":[{"type":"string","maxLength":256},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}]},"description":"Custom key-value pairs for tracking context. Keys must be alphanumeric with underscores, max 64 chars. Values can be strings (max 256 chars), numbers, or arrays. Maximum 20 pairs.","example":{"includeMEP":true,"source":"takeoff-api","projectId":"proj-123"}}},"required":["type"]},"MatchingResponse":{"type":"object","properties":{"estimateJobId":{"type":"string","format":"uuid","description":"Unique identifier for the matching job (same identifier space as /v1/estimate)","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"status":{"type":"string","enum":["PENDING","EXTRACTING","LABELING","AGENTIC_TAKEOFF","INGESTING","PROCESSING","HUMAN_REVIEW","COMPLETED","FAILED","CANCELED"],"description":"Current job status. Possible values: PENDING, EXTRACTING, LABELING, AGENTIC_TAKEOFF, INGESTING, PROCESSING, HUMAN_REVIEW, COMPLETED, FAILED, CANCELED","example":"PENDING"},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of job creation","example":"2025-12-05T14:30:00Z"},"estimatedCompletionTime":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of expected completion","example":"2025-12-05T14:35:00Z"},"metadata":{"type":"object","additionalProperties":{"anyOf":[{"type":"string","maxLength":256},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}]},"description":"Custom metadata provided during job creation","example":{"includeMEP":true,"source":"takeoff-api","projectId":"proj-123"}}},"required":["estimateJobId","status","createdAt","estimatedCompletionTime"]},"MatchingItem":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":200,"description":"Partner-supplied line identifier","example":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"},"productName":{"type":"string","minLength":1,"maxLength":500,"description":"Product description to match against the catalog.","example":"2x6 PT Bottom Plates"},"group":{"type":"string","maxLength":200,"description":"Section/category grouping (e.g. Framing). Improves matching accuracy.","example":"Framing"},"unit":{"type":"string","maxLength":50,"description":"Unit of measure. Accepts any string and is normalized to one of: EA, PC, SET, SHT, IN, FT, LF, YD, MI, SF, SY, SQ, AC, CF, CY, GAL, QT, LB, TON, HRS, HR, MH, DY, WK, MO, BAG, BF, BOX, BDL, LS, PK, ROLL. Common variations (e.g. \"SQFT\" -> \"SF\", \"Box\"/\"Boxes\" -> \"BOX\", \"Piece\"/\"Pieces\" -> \"PC\", \"Rolls\" -> \"ROLL\") are mapped automatically. Unknown values default to EA.","example":"LF"},"quantity":{"type":"number","minimum":0,"exclusiveMinimum":true,"description":"Quantity for this item. Must be a positive number. Defaults to 1 when omitted.","example":22}},"required":["id","productName"]},"MatchingRequest":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/MatchingItem"},"minItems":1,"maxItems":2000,"description":"Partner-supplied items to match against the catalog"},"address":{"type":"string","maxLength":200,"description":"Address or ZIP code for regional availability and pricing","example":"Austin, TX 78701"},"callbackUrl":{"type":"string","format":"uri","description":"Webhook URL for job completion notification","example":"https://partners.example.com/webhooks/estimate"},"externalId":{"type":"string","maxLength":200,"description":"Partner's internal id for this matching request","example":"MATCH-12345"},"storeReference":{"type":"string","maxLength":200,"description":"Store, location, or region identifier for applying store-level AI presets","example":"STORE-NYC-001"},"customerReference":{"type":"string","maxLength":200,"description":"Partner's internal reference for tracking specific customers and applying customer-level AI presets","example":"CUST-ORD-789"},"metadata":{"type":"object","additionalProperties":{"anyOf":[{"type":"string","maxLength":256},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}]},"description":"Custom key-value pairs for tracking context. Keys must be alphanumeric with underscores, max 64 chars. Values can be strings (max 256 chars), numbers, or arrays. Maximum 20 pairs.","example":{"includeMEP":true,"source":"takeoff-api","projectId":"proj-123"}}},"required":["items"]},"EstimateFile":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Unique identifier for the file","example":"f1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"filename":{"type":"string","description":"Original filename","example":"floor_plan.pdf"},"contentType":{"type":"string","description":"MIME type of the file","example":"application/pdf"},"sizeBytes":{"type":"integer","description":"File size in bytes","example":1048576},"confidence":{"type":"string","nullable":true,"enum":["HIGH","MEDIUM","LOW"],"description":"Confidence level of the file processing result, or null if not assessed","example":"HIGH"}},"required":["id","filename","contentType","sizeBytes","confidence"]},"TakeoffFileReady":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Stable identifier for the file; does not change across webhook retries","example":"a7c31c2b-9f4e-5d3a-8b1c-2e6f4a5b6c7d"},"filename":{"type":"string","description":"File name including extension","example":"takeoff-markup.pdf"},"fileType":{"type":"string","enum":["TAKEOFF_MARKUP"],"description":"Deliverable type","example":"TAKEOFF_MARKUP"},"contentType":{"type":"string","description":"MIME type of the file","example":"application/pdf"},"sizeBytes":{"type":"integer","description":"File size in bytes","example":2456789},"status":{"type":"string","enum":["READY"],"description":"The file is persisted and downloadable","example":"READY"},"checksum":{"type":"string","description":"SHA-256 checksum of the file bytes, format sha256:<hex>","example":"sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}},"required":["id","filename","fileType","contentType","sizeBytes","status","checksum"]},"TakeoffFileError":{"type":"object","properties":{"code":{"type":"string","enum":["FILE_PROCESSING_FAILED","FILE_EXTRACTION_NO_ITEMS","FILE_NO_CONTENT","CONFIGURATION_ERROR","AI_ESTIMATE_GENERATION_ERROR","PROMPT_GENERATION_NO_ITEMS","INVALID_PROMPT","MARKUP_GENERATION_FAILED","PROCESSING_TIMEOUT","ESTIMATE_CANCELLED","INTERNAL_ERROR"],"description":"Why the deliverable could not be produced","example":"MARKUP_GENERATION_FAILED"},"message":{"type":"string","description":"Human-readable description","example":"Unable to generate the takeoff markup file"}},"required":["code","message"]},"TakeoffFileFailed":{"type":"object","properties":{"fileType":{"type":"string","enum":["TAKEOFF_MARKUP"],"description":"Deliverable type that failed","example":"TAKEOFF_MARKUP"},"status":{"type":"string","enum":["FAILED"],"description":"The file could not be produced and will not arrive","example":"FAILED"},"error":{"$ref":"#/components/schemas/TakeoffFileError"}},"required":["fileType","status","error"],"additionalProperties":false},"TakeoffFile":{"oneOf":[{"$ref":"#/components/schemas/TakeoffFileReady"},{"$ref":"#/components/schemas/TakeoffFileFailed"}],"discriminator":{"propertyName":"status","mapping":{"READY":"#/components/schemas/TakeoffFileReady","FAILED":"#/components/schemas/TakeoffFileFailed"}}},"EstimateItemAttributes":{"type":"object","properties":{"wasteFactor":{"type":"number","description":"Percentage to over-buy for waste (e.g., 0.10 for 10%)","example":0.1},"location":{"type":"string","nullable":true,"description":"Floor or area designation for bundling deliveries","example":"First Floor"},"building":{"type":"string","nullable":true,"description":"Named structure on the project site this item belongs to, for scoping and bundling work per building on a multi-building site.","example":"Main House"},"categoryName":{"type":"string","description":"Category name","example":"Lumber & Composites"},"categoryCode":{"type":"string","description":"Category code (1-2 letters)","example":"L"},"extractedLabel":{"type":"string","description":"Extracted label from the original description","example":"200 - 2x6 PT Bottom Plates (1234567)"},"contextualSection":{"type":"string","description":"Contextual section name","example":"Framing"},"partnerItemId":{"type":"string","description":"Partner-supplied line item ID","example":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}},"description":"Material attributes that apply to this item regardless of product match"},"EstimateItemProductMatchAttributes":{"type":"object","properties":{"productId":{"type":"string","description":"Product ID from the URL","example":"4564606"},"productUnitRatio":{"type":"object","additionalProperties":{"nullable":true},"description":"Conversion ratios between different units of measure","example":{"EA":1,"LF":8}},"conversionRatio":{"type":"number","description":"Numerical conversion value based on unit of measure","example":8},"quantitySellableRound":{"type":"number","description":"Rounded quantity to purchase accounting for waste and conversions","example":427},"unitSellable":{"type":"string","description":"Unit of measure for the sellable item","example":"EA"},"rateAmountSellable":{"type":"number","description":"List price per sellable unit as an integer in smallest currency unit (e.g., cents for USD). Example: 897 = $8.97","example":3200},"totalAmountSellable":{"type":"number","description":"Total cost for the line item as an integer in smallest currency unit (e.g., cents for USD). Example: 89700 = $897.00","example":136640},"itemNumber":{"type":"string","description":"Associate-facing item number","example":"196037"},"modelNumber":{"type":"string","description":"Manufacturer model number","example":"OG2P20608-AG"},"productImageUrl":{"type":"string","description":"URL to the product image","example":"https://handoff.ai/exampleimage/4564606/4564606.jpg"},"partialMatch":{"type":"boolean","description":"Whether the match is a partial match","example":true}}},"EstimateItemProductMatch":{"type":"object","nullable":true,"properties":{"supplier":{"type":"string","description":"Supplier name","example":"Lowe's"},"productName":{"type":"string","description":"Name of the matched product","example":"Severe Weather 2-in x 6-in x 8-ft #2 Prime Southern yellow pine Above ground Pressure Treated Lumber"},"productUrl":{"type":"string","nullable":true,"description":"Full URL to the product page","example":"https://www.lowes.com/pd/Severe-Weather-Common-2-in-x-6-in-x-8-ft-Actual-1-5-in-x-5-5-in-x-8-ft-2-Prime-Treated-Lumber/4564606"},"productBrand":{"type":"string","nullable":true,"description":"Product brand name","example":"Severe Weather"},"attributes":{"$ref":"#/components/schemas/EstimateItemProductMatchAttributes"}},"required":["supplier","productName","productUrl","productBrand","attributes"],"description":"Matched product details from supplier"},"EstimateItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Unique identifier for this estimate item","example":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"},"group":{"type":"string","nullable":true,"description":"Trade group name","example":"Framing"},"productName":{"type":"string","nullable":true,"description":"Item title","example":"2x6 PT Bottom Plates at First Floor"},"quantity":{"type":"number","nullable":true,"description":"Measured quantity","example":3100},"rateAmount":{"type":"number","description":"Price per measured unit as an integer in smallest currency unit (e.g., cents for USD). Example: 897 = $8.97","example":50},"totalAmount":{"type":"number","description":"Total cost as an integer in smallest currency unit (e.g., cents for USD). Example: 89700 = $897.00","example":155000},"description":{"type":"string","nullable":true,"description":"Long form description","example":"Pressure treated bottom plates for first floor framing"},"category":{"type":"string","nullable":true,"description":"Item category","example":"Material"},"unit":{"type":"string","nullable":true,"enum":["EACH","PIECE","SET","SHEET","INCH","FOOT","LINEAR_FOOT","YARD","MILE","SQUARE_FOOT","SQUARE_YARD","SQUARE","ACRE","CUBIC_FOOT","CUBIC_YARD","GALLON","QUART","POUND","TON","HOUR","MAN_HOUR","DAY","WEEK","MONTH","BAG","BOARD_FOOT","BOX","BUNDLE","LUMP_SUM","PACKAGE","ROLL"],"description":"Unit of measure. Possible values: EACH, PIECE, SET, SHEET, INCH, FOOT, LINEAR_FOOT, YARD, MILE, SQUARE_FOOT, SQUARE_YARD, SQUARE, ACRE, CUBIC_FOOT, CUBIC_YARD, GALLON, QUART, POUND, TON, HOUR, HOUR, MAN_HOUR, DAY, WEEK, MONTH, BAG, BOARD_FOOT, BOX, BUNDLE, LUMP_SUM, PACKAGE, ROLL","example":"LINEAR_FOOT"},"unitAbbreviation":{"type":"string","enum":["EA","PC","SET","SHT","IN","FT","LF","YD","MI","SF","SY","SQ","AC","CF","CY","GAL","QT","LB","TON","HRS","HR","MH","DY","WK","MO","BAG","BF","BOX","BDL","LS","PK","ROLL"],"description":"Short abbreviation for the unit of measure. Possible values: EA, PC, SET, SHT, IN, FT, LF, YD, MI, SF, SY, SQ, AC, CF, CY, GAL, QT, LB, TON, HRS, HR, MH, DY, WK, MO, BAG, BF, BOX, BDL, LS, PK, ROLL","example":"LF"},"currency":{"type":"string","description":"Currency code","example":"USD"},"attributes":{"$ref":"#/components/schemas/EstimateItemAttributes"},"productMatch":{"$ref":"#/components/schemas/EstimateItemProductMatch"},"districtAvailability":{"type":"string","nullable":true,"description":"Product availability in the district: 'in-store', 'in-district', or 'direct-match'","example":"in-store"}},"required":["id","group","productName","quantity","rateAmount","totalAmount","description","category","unit","unitAbbreviation","currency","productMatch"]},"EstimateResultSummary":{"type":"object","properties":{"description":{"type":"string","nullable":true,"description":"Summary description of the estimate scope of work","example":"This estimate covers first floor framing including walls, headers, and floor joists for a 2,500 sq ft residential addition."},"totalItems":{"type":"number","description":"Total number of material items eligible for matching","example":25},"matchedItems":{"type":"number","description":"Number of items with product matches eligible for matching","example":23},"unmatchedItems":{"type":"number","description":"Number of items without product matches eligible for matching","example":2},"matchingPercentage":{"type":"number","description":"Percentage of items matched to products","example":92},"totalAmount":{"type":"number","description":"Total cost of all items as an integer in smallest currency unit (e.g., cents for USD). Example: 897000 = $8,970.00","example":1550000},"currency":{"type":"string","description":"Currency code","example":"USD"}},"required":["description","totalItems","matchedItems","unmatchedItems","totalAmount","currency"]},"EstimateResult":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/EstimateItem"},"description":"List of items in the estimate"},"summary":{"$ref":"#/components/schemas/EstimateResultSummary"},"message":{"type":"string","nullable":true,"description":"The AI assistant's natural-language response for this estimate version (what it said it did, or why it made no changes on an update).","example":"I increased the drywall quantity to 1,200 SF and added two interior doors as requested."}},"required":["items","summary"]},"ErrorInfo":{"type":"object","properties":{"code":{"type":"string","example":"ESTIMATE_FAILED"},"message":{"type":"string","example":"Unable to process estimate"},"details":{"type":"string"}},"required":["code","message"]},"EstimateGetResponse":{"type":"object","properties":{"estimateJobId":{"type":"string","format":"uuid","description":"Unique identifier for the estimate generation job","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"externalId":{"type":"string","description":"Partner's internal estimate ID provided during job creation","example":"EST-12345"},"type":{"type":"string","enum":["MATERIAL_LIST","BLUEPRINT"],"description":"Type of estimate request: 'MATERIAL_LIST' for material lists or 'BLUEPRINT' for blueprints","example":"MATERIAL_LIST"},"fileIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"List of file IDs used to generate this estimate (deprecated: use 'files' for full metadata)","example":["f1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c","f2b3c4d5-e6f7-4a8b-9c0d-1e2f3a4b5c6d"],"deprecated":true},"files":{"type":"array","items":{"$ref":"#/components/schemas/EstimateFile"},"description":"List of files used to generate this estimate with their metadata and confidence scores","example":[{"id":"f1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c","filename":"floor_plan.pdf","contentType":"application/pdf","sizeBytes":1048576,"confidence":"HIGH"},{"id":"f2b3c4d5-e6f7-4a8b-9c0d-1e2f3a4b5c6d","filename":"materials.xlsx","contentType":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet","sizeBytes":524288,"confidence":null}]},"takeoffFiles":{"type":"array","items":{"$ref":"#/components/schemas/TakeoffFile"},"description":"Generated takeoff deliverables for this version, mirroring the estimate webhook's takeoffFiles. Download each via GET /v1/estimate/{estimateJobId}/files/{fileId}/download."},"address":{"type":"string","description":"Address or ZIP code for regional availability and pricing","example":"Austin, TX 78701"},"prompt":{"type":"string","description":"Special instructions and scope of work for the estimate (e.g., 'Create an estimate for framing and painting.')","example":"Create an estimate for framing and painting."},"customerReference":{"type":"string","description":"Partner's internal reference/order number for tracking specific customers","example":"CUST-ORD-789"},"callbackUrl":{"type":"string","description":"Webhook URL for job completion notification","example":"https://partners.example.com/webhooks/estimate"},"status":{"type":"string","enum":["PENDING","EXTRACTING","LABELING","AGENTIC_TAKEOFF","INGESTING","PROCESSING","HUMAN_REVIEW","COMPLETED","FAILED","CANCELED"],"description":"Current status. Possible values: PENDING, EXTRACTING, LABELING, AGENTIC_TAKEOFF, INGESTING, PROCESSING, HUMAN_REVIEW, COMPLETED, FAILED, CANCELED","example":"COMPLETED"},"createdAt":{"type":"string","example":"2025-12-05T14:30:00Z"},"estimatedCompletionTime":{"type":"string","example":"2025-12-05T14:31:00Z"},"completedAt":{"type":"string","example":"2025-12-05T14:30:45Z"},"failedAt":{"type":"string","example":"2025-12-05T14:30:45Z"},"humanReviewStartedAt":{"type":"string","description":"ISO 8601 timestamp of when human review started (if status is HUMAN_REVIEW)","example":"2025-12-05T14:30:45Z"},"version":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Version number of the estimate being returned (1, 2, 3, ...)","example":1},"currentVersion":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"The current/latest version number for this estimate job","example":2},"result":{"$ref":"#/components/schemas/EstimateResult"},"error":{"$ref":"#/components/schemas/ErrorInfo"},"metadata":{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}]},"description":"Custom key-value pairs for tracking context","example":{"source":"takeoff-api","projectId":"proj-123","quantity":100}}},"required":["estimateJobId","type","fileIds","files","takeoffFiles","status","createdAt"]},"EstimateFileDownloadResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"File identifier; matches takeoffFiles[].id from the estimate webhook","example":"a7c31c2b-9f4e-5d3a-8b1c-2e6f4a5b6c7d"},"filename":{"type":"string","description":"File name including extension","example":"takeoff-markup.pdf"},"contentType":{"type":"string","description":"MIME type of the file","example":"application/pdf"},"sizeBytes":{"type":"integer","description":"File size in bytes","example":2456789},"downloadUrl":{"type":"string","format":"uri","description":"Presigned S3 GET URL. Treat it as a bearer credential: do not log or forward it. Valid for 15 minutes; request a new one whenever you need it.","example":"https://bucket.s3.amazonaws.com/enterprise/...?X-Amz-Signature=..."},"expiresAt":{"type":"string","format":"date-time","description":"When downloadUrl stops working (ISO 8601)","example":"2026-08-31T16:00:00.000Z"}},"required":["id","filename","contentType","sizeBytes","downloadUrl","expiresAt"]},"TradeReportSummaryGetResponse":{"type":"object","properties":{"estimateJobId":{"type":"string","format":"uuid","description":"Unique identifier for the estimate generation job","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"externalId":{"type":"string","description":"Partner's internal estimate ID provided during job creation","example":"EST-12345"},"version":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Version number the summary was generated for (1, 2, 3, ...)","example":1},"summary":{"type":"string","description":"LLM-condensed trade report summary in markdown","example":"## Project Overview\n\n...\n\n## Framing\n\n..."},"projectSummary":{"type":"string","nullable":true,"description":"Overall project summary from the foundation index, if available","example":"Two-story single-family residence, 2,400 sqft ..."},"model":{"type":"string","nullable":true,"description":"LLM model used to generate the summary","example":"gpt-4.1-mini"},"createdAt":{"type":"string","example":"2025-12-05T14:30:00.000Z"},"updatedAt":{"type":"string","example":"2025-12-05T14:30:00.000Z"}},"required":["estimateJobId","version","summary","projectSummary","model","createdAt","updatedAt"]},"TradeReportSummaryWebhookPayload":{"type":"object","properties":{"event":{"type":"string","enum":["trade_report_summary.ready"],"description":"Webhook event type","example":"trade_report_summary.ready"},"estimateJobId":{"type":"string","format":"uuid","description":"Unique identifier for the estimate generation job","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"externalId":{"type":"string","nullable":true,"description":"Partner's internal estimate ID provided during job creation","example":"EST-12345"},"version":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Version number of the estimate the summary was generated for (1, 2, 3, ...)","example":1},"summary":{"type":"string","description":"The LLM-condensed trade report summary in markdown","example":"## Project Overview\n\n...\n\n## Framing\n\n..."},"projectSummary":{"type":"string","nullable":true,"description":"Overall project summary from the foundation index, if available","example":"Two-story single-family residence, 2,400 sqft ..."},"resultUrl":{"type":"string","format":"uri","description":"URL to re-retrieve the trade report summary via GET request","example":"https://api.handoff.ai/v1/estimate/e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c/trade-report-summary"},"timestamp":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of when the summary was generated","example":"2024-01-15T10:30:00.000Z"}},"required":["event","estimateJobId","externalId","version","summary","projectSummary","resultUrl","timestamp"]},"EstimateUpdateResponse":{"type":"object","properties":{"estimateJobId":{"type":"string","format":"uuid","description":"Unique identifier for the estimate job","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"status":{"type":"string","enum":["PENDING","EXTRACTING","LABELING","AGENTIC_TAKEOFF","INGESTING","PROCESSING","HUMAN_REVIEW","COMPLETED","FAILED","CANCELED"],"description":"Current job status. Possible values: PENDING, EXTRACTING, LABELING, AGENTIC_TAKEOFF, INGESTING, PROCESSING, HUMAN_REVIEW, COMPLETED, FAILED, CANCELED","example":"PENDING"},"versionNumber":{"type":"number","description":"Version number of the new estimate version","example":2},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of version creation","example":"2025-12-05T14:30:00Z"},"estimatedCompletionTime":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of expected completion","example":"2025-12-05T14:31:00Z"},"streamUrl":{"type":"string","description":"Server-Sent Events (SSE) URL for live AI-agent + matching progress while this update processes. Optional to consume; the authoritative result still arrives via webhook and GET. Reconnect with the Last-Event-ID header to resume.","example":"https://api.example.com/v1/estimate/e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c/stream"},"metadata":{"type":"object","additionalProperties":{"anyOf":[{"type":"string","maxLength":256},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}]},"description":"Custom metadata provided during job creation or update","example":{"includeMEP":true,"source":"takeoff-api","projectId":"proj-123"}}},"required":["estimateJobId","status","versionNumber","createdAt","estimatedCompletionTime","streamUrl"]},"EstimateUpdateInputItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Unique identifier for this estimate item","example":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"},"group":{"type":"string","nullable":true,"description":"Trade group name","example":"Framing"},"productName":{"type":"string","description":"Item title","example":"2x6 PT Bottom Plates at First Floor"},"quantity":{"type":"number","nullable":true,"description":"Measured quantity","example":3100},"rateAmount":{"type":"number","default":0,"description":"Price per measured unit as an integer in smallest currency unit (e.g., cents for USD). Defaults to 0 — when a productId is provided, current pricing is looked up downstream.","example":0},"totalAmount":{"type":"number","default":0,"description":"Total cost as an integer in smallest currency unit (e.g., cents for USD). Defaults to 0 — when a productId is provided, current pricing is looked up downstream.","example":0},"description":{"type":"string","nullable":true,"description":"Long form description","example":"Pressure treated bottom plates for first floor framing"},"category":{"type":"string","enum":["material","labor","other"],"description":"Item category. One of: material, labor, other. Unrecognized or missing values default to \"material\".","example":"material"},"unit":{"type":"string","nullable":true,"enum":["EACH","PIECE","SET","SHEET","INCH","FOOT","LINEAR_FOOT","YARD","MILE","SQUARE_FOOT","SQUARE_YARD","SQUARE","ACRE","CUBIC_FOOT","CUBIC_YARD","GALLON","QUART","POUND","TON","HOUR","MAN_HOUR","DAY","WEEK","MONTH","BAG","BOARD_FOOT","BOX","BUNDLE","LUMP_SUM","PACKAGE","ROLL"],"description":"Unit of measure. Possible values: EACH, PIECE, SET, SHEET, INCH, FOOT, LINEAR_FOOT, YARD, MILE, SQUARE_FOOT, SQUARE_YARD, SQUARE, ACRE, CUBIC_FOOT, CUBIC_YARD, GALLON, QUART, POUND, TON, HOUR, HOUR, MAN_HOUR, DAY, WEEK, MONTH, BAG, BOARD_FOOT, BOX, BUNDLE, LUMP_SUM, PACKAGE, ROLL","example":"LINEAR_FOOT"},"unitAbbreviation":{"type":"string","enum":["EA","PC","SET","SHT","IN","FT","LF","YD","MI","SF","SY","SQ","AC","CF","CY","GAL","QT","LB","TON","HRS","HR","MH","DY","WK","MO","BAG","BF","BOX","BDL","LS","PK","ROLL"],"description":"Short abbreviation for the unit of measure. Possible values: EA, PC, SET, SHT, IN, FT, LF, YD, MI, SF, SY, SQ, AC, CF, CY, GAL, QT, LB, TON, HRS, HR, MH, DY, WK, MO, BAG, BF, BOX, BDL, LS, PK, ROLL","example":"LF"},"currency":{"type":"string","description":"Currency code","example":"USD"},"attributes":{"$ref":"#/components/schemas/EstimateItemAttributes"},"productId":{"type":"string","nullable":true,"description":"Supplier product identifier. Forwarded to the matching backend so it can look up current pricing — no need to send productMatch on updates.","example":"4564606"}},"required":["id","group","productName","quantity","description","unitAbbreviation","currency"]},"EstimateUpdateRequest":{"type":"object","properties":{"prompt":{"type":"string","maxLength":10000,"description":"Instructions for what to change in the estimate","example":"Add 20 bags of concrete mix and remove the drywall items"},"fileIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"Additional file IDs to attach to the estimate job. These are additive — they do not replace existing files.","example":["f1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"]},"items":{"type":"array","items":{"$ref":"#/components/schemas/EstimateUpdateInputItem"},"description":"Array of estimate items to use as the base for the update. If not provided, the most recent completed version's items will be used. `rateAmount` and `totalAmount` are optional and default to 0 — when `productId` is provided, current pricing is looked up downstream.","example":[{"id":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d","group":"Framing","productName":"2x6 PT Bottom Plates","quantity":3100,"description":"Pressure treated bottom plates","category":"material","unitAbbreviation":"LF","currency":"USD","productId":"4564606"}]},"callbackUrl":{"type":"string","format":"uri","description":"Webhook URL for job completion notification. If provided, updates the job's callback URL.","example":"https://partners.example.com/webhooks/estimate"},"metadata":{"type":"object","additionalProperties":{"anyOf":[{"type":"string","maxLength":256},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}]},"description":"Custom key-value pairs for tracking context. Keys must be alphanumeric with underscores, max 64 chars. Values can be strings (max 256 chars), numbers, or arrays. Maximum 20 pairs.","example":{"includeMEP":true,"source":"takeoff-api","projectId":"proj-123"}}},"required":["prompt"]},"EstimateStreamEvent":{"oneOf":[{"type":"object","properties":{"timestamp":{"type":"string"},"type":{"type":"string","enum":["phase"]},"phase":{"type":"string","enum":["extracting","editing","matching","finalizing","complete"]},"detail":{"type":"string","nullable":true}},"required":["type","phase"]},{"type":"object","properties":{"timestamp":{"type":"string"},"type":{"type":"string","enum":["status"]},"state":{"type":"string","nullable":true},"title":{"type":"string","nullable":true},"detail":{"type":"string","nullable":true}},"required":["type"]},{"type":"object","properties":{"timestamp":{"type":"string"},"type":{"type":"string","enum":["message"]},"delta":{"type":"string","nullable":true},"final":{"type":"string","nullable":true},"isComplete":{"type":"boolean"}},"required":["type"]},{"type":"object","properties":{"timestamp":{"type":"string"},"type":{"type":"string","enum":["estimate_change"]},"change":{"type":"object","properties":{"op":{"type":"string","enum":["add","update","remove"]}},"required":["op"],"additionalProperties":{"nullable":true}},"isComplete":{"type":"boolean"}},"required":["type","change"]},{"type":"object","properties":{"timestamp":{"type":"string"},"type":{"type":"string","enum":["matching_progress"]},"matched":{"type":"number"},"total":{"type":"number"}},"required":["type","matched","total"]},{"type":"object","properties":{"timestamp":{"type":"string"},"type":{"type":"string","enum":["done"]},"status":{"type":"string"},"version":{"type":"number"},"resultUrl":{"type":"string"}},"required":["type","status","version"]},{"type":"object","properties":{"timestamp":{"type":"string"},"type":{"type":"string","enum":["error"]},"message":{"type":"string"}},"required":["type","message"]}],"description":"A single Server-Sent Event payload. The SSE `event:` field carries the event `type`; the `data:` field is this JSON object. Every event has a `type` and ISO `timestamp`.","example":{"type":"phase","phase":"extracting","detail":"Extracting 2 file(s)…","timestamp":"2026-07-08T14:30:05Z"}},"WebhookError":{"type":"object","nullable":true,"properties":{"code":{"type":"string","enum":["FILE_PROCESSING_FAILED","FILE_EXTRACTION_NO_ITEMS","FILE_NO_CONTENT","CONFIGURATION_ERROR","AI_ESTIMATE_GENERATION_ERROR","PROMPT_GENERATION_NO_ITEMS","INVALID_PROMPT","MARKUP_GENERATION_FAILED","PROCESSING_TIMEOUT","ESTIMATE_CANCELLED","INTERNAL_ERROR"],"description":"Error code indicating what type of failure occurred","example":"FILE_PROCESSING_FAILED"},"message":{"type":"string","description":"Human-readable error message","example":"One or more files failed to process"},"details":{"type":"string","description":"Additional error details (e.g., failed file IDs)","example":"Failed file IDs: file-123, file-456"}},"required":["code","message"],"description":"Error information (present when status is FAILED, null otherwise)"},"EstimateWebhookPayload":{"type":"object","properties":{"event":{"type":"string","enum":["estimate.updated"],"description":"Webhook event type","example":"estimate.updated"},"estimateJobId":{"type":"string","format":"uuid","description":"Unique identifier for the estimate generation job","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"externalId":{"type":"string","nullable":true,"description":"Partner's internal estimate ID provided during job creation","example":"EST-12345"},"type":{"type":"string","enum":["MATERIAL_LIST","BLUEPRINT"],"description":"Type of estimate request: 'MATERIAL_LIST' for material lists or 'BLUEPRINT' for blueprints","example":"MATERIAL_LIST"},"fileIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"List of file IDs used to generate this estimate (deprecated: use 'files' for full metadata)","example":["f1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c","f2b3c4d5-e6f7-4a8b-9c0d-1e2f3a4b5c6d"],"deprecated":true},"files":{"type":"array","items":{"$ref":"#/components/schemas/EstimateFile"},"description":"List of files used to generate this estimate with their metadata and confidence scores","example":[{"id":"f1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c","filename":"floor_plan.pdf","contentType":"application/pdf","sizeBytes":1048576,"confidence":"HIGH"},{"id":"f2b3c4d5-e6f7-4a8b-9c0d-1e2f3a4b5c6d","filename":"materials.xlsx","contentType":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet","sizeBytes":524288,"confidence":null}]},"takeoffFiles":{"type":"array","items":{"$ref":"#/components/schemas/TakeoffFile"},"description":"Generated takeoff deliverables for this version, one entry per deliverable. status READY = downloadable via GET /v1/estimate/{estimateJobId}/files/{fileId}/download; status FAILED = the file could not be produced and will not arrive (the job itself still succeeded). Empty when no deliverable applies (material lists, clients not on the delivery contract, non-terminal statuses).","example":[{"id":"a7c31c2b-9f4e-5d3a-8b1c-2e6f4a5b6c7d","filename":"takeoff-markup.pdf","fileType":"TAKEOFF_MARKUP","contentType":"application/pdf","sizeBytes":2456789,"status":"READY","checksum":"sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}]},"address":{"type":"string","nullable":true,"description":"Address or ZIP code for regional availability and pricing","example":"Austin, TX 78701"},"prompt":{"type":"string","nullable":true,"description":"Special instructions and scope of work for the estimate (e.g., 'Create an estimate for framing and painting.')","example":"Create an estimate for framing and painting."},"customerReference":{"type":"string","nullable":true,"description":"Partner's internal reference/order number for tracking specific customers","example":"CUST-ORD-789"},"callbackUrl":{"type":"string","nullable":true,"description":"Webhook URL for job completion notification","example":"https://partners.example.com/webhooks/estimate"},"status":{"type":"string","enum":["PENDING","EXTRACTING","LABELING","AGENTIC_TAKEOFF","INGESTING","PROCESSING","HUMAN_REVIEW","COMPLETED","FAILED","CANCELED"],"description":"Job status. Possible values: PENDING, EXTRACTING, LABELING, AGENTIC_TAKEOFF, INGESTING, PROCESSING, HUMAN_REVIEW, COMPLETED, FAILED, CANCELED","example":"COMPLETED"},"resultUrl":{"type":"string","format":"uri","description":"URL to retrieve full job results via GET request","example":"https://api.handoff.ai/v1/estimate/e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"version":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Version number of the estimate (1, 2, 3, ...)","example":1},"metadata":{"type":"object","additionalProperties":{"anyOf":[{"type":"string","maxLength":256},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}]},"description":"Custom metadata provided during job creation","example":{"includeMEP":true,"source":"takeoff-api","projectId":"proj-123"}},"timestamp":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of the status change","example":"2024-01-15T10:30:00.000Z"},"result":{"allOf":[{"$ref":"#/components/schemas/EstimateResult"},{"nullable":true,"description":"Estimate results (present when status is COMPLETED or HUMAN_REVIEW, null otherwise)"}]},"error":{"$ref":"#/components/schemas/WebhookError"}},"required":["event","estimateJobId","externalId","type","fileIds","files","takeoffFiles","address","prompt","customerReference","callbackUrl","status","resultUrl","version","timestamp","result","error"]},"WebhookOauthResponse":{"type":"object","properties":{"clientId":{"type":"string","description":"OAuth client ID","example":"webhook-client-123"},"tokenEndpointUrl":{"type":"string","format":"uri","description":"OAuth token endpoint URL","example":"https://auth.example.com/oauth/token"},"scope":{"type":"string","nullable":true,"description":"Space-separated OAuth scopes","example":"webhooks:receive"},"audience":{"type":"string","nullable":true,"description":"OAuth audience/resource identifier","example":"https://api.example.com"},"grantType":{"type":"string","description":"OAuth grant type","example":"client_credentials"},"enabled":{"type":"boolean","description":"Whether OAuth webhook authentication is enabled","example":true}},"required":["clientId","tokenEndpointUrl","scope","audience","grantType","enabled"]},"SetWebhookOauthRequest":{"type":"object","properties":{"clientId":{"type":"string","minLength":1,"description":"OAuth client ID for your webhook endpoint's auth server","example":"webhook-client-123"},"clientSecret":{"type":"string","minLength":1,"description":"OAuth client secret (stored securely encrypted)","example":"secret_xxxxxxxxxxxxx"},"tokenEndpointUrl":{"type":"string","format":"uri","description":"OAuth token endpoint URL","example":"https://auth.example.com/oauth/token"},"scope":{"type":"string","description":"Space-separated scopes to request (optional)","example":"webhooks:receive"},"audience":{"type":"string","description":"Audience/resource identifier for Auth0, etc. (optional)","example":"https://api.example.com"},"grantType":{"type":"string","enum":["client_credentials"],"description":"OAuth grant type (only client_credentials is supported)","example":"client_credentials"}},"required":["clientId","clientSecret","tokenEndpointUrl","grantType"]},"WebhookHmacResponse":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether HMAC signing is enabled for webhooks","example":true},"algorithm":{"type":"string","description":"HMAC algorithm used for signing","example":"sha256"},"headerName":{"type":"string","description":"HTTP header containing the signature","example":"X-Handoff-Signature"},"timestampHeaderName":{"type":"string","description":"HTTP header containing the timestamp","example":"X-Handoff-Timestamp"}},"required":["enabled","algorithm","headerName","timestampHeaderName"]},"SetWebhookHmacRequest":{"type":"object","properties":{"secret":{"type":"string","minLength":32,"description":"HMAC secret for signing webhook payloads (minimum 32 characters)","example":"whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}},"required":["secret"]},"PresetForType":{"type":"string","enum":["MATCHING","ESTIMATE","TAKEOFF","TRADE_REPORT"],"description":"Preset workflow type","example":"MATCHING"},"PresetsResponse":{"type":"object","properties":{"presetId":{"type":"string","nullable":true,"format":"uuid","description":"Unique identifier for this preset configuration. Null if no presets exist yet.","example":"e3f4a5b6-c7d8-4e9f-0a1b-2c3d4e5f6a7b"},"storeReference":{"type":"string","nullable":true,"description":"Store reference if store-specific, null otherwise","example":"STORE-NYC-001"},"customerReference":{"type":"string","nullable":true,"description":"Customer reference if customer-specific, null otherwise","example":null},"forType":{"$ref":"#/components/schemas/PresetForType"},"presets":{"type":"array","items":{"type":"string"},"description":"List of preset instructions","example":["Include 10% waste factor for all lumber","Prefer in-stock items over special orders","Use premium grade materials for structural components"]},"version":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Monotonic version number for this preset row. Starts at 1 and increments on every write.","example":3},"changeDescription":{"type":"string","nullable":true,"description":"Description of the change that produced the current version, or null for pre-versioning rows.","example":"Added premium-materials preset for high-end residential customers"},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of preset creation","example":"2025-12-05T16:30:00Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of last update","example":"2025-12-05T16:30:00Z"},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-fatal validation warnings for the saved presets (e.g. a TAKEOFF_PRODUCT: tag naming an unknown product — such presets never apply to any job until fixed)","example":["Preset 2 has an unrecognized TAKEOFF_PRODUCT tag (\"WINDOW_DOORS\") and will never apply to any job"]}},"required":["presetId","storeReference","customerReference","forType","presets","version","changeDescription","createdAt","updatedAt"]},"SetPresetsRequest":{"type":"object","properties":{"forType":{"allOf":[{"$ref":"#/components/schemas/PresetForType"},{"default":"MATCHING","description":"Preset workflow type. Defaults to MATCHING."}]},"presets":{"type":"array","items":{"type":"string","minLength":1,"maxLength":2000},"maxItems":1000,"description":"List of preset instructions for the selected workflow type. An empty array clears all presets for the type and scope while preserving version history.","example":["Include 10% waste factor for all lumber","Prefer in-stock items over special orders","Use premium grade materials for structural components"]},"storeReference":{"type":"string","maxLength":200,"description":"Store, location, or region identifier. If provided, presets apply to the selected workflow type for this store/region.","example":"STORE-NYC-001"},"customerReference":{"type":"string","maxLength":200,"description":"Customer identifier. If provided, presets apply to the selected workflow type for this customer. Takes priority over store-level presets.","example":"CUST-12345"},"changeDescription":{"type":"string","maxLength":500,"description":"Human-readable description of what changed in this version. Required when called from the internal admin UI; optional for external API consumers (backward-compatible).","example":"Added premium-materials preset for high-end residential customers"},"targetClientId":{"type":"string","format":"uuid"}},"required":["presets"]},"PresetVersionSummary":{"type":"object","properties":{"presetId":{"type":"string","format":"uuid","description":"Identifier of the preset row this version belongs to.","example":"e3f4a5b6-c7d8-4e9f-0a1b-2c3d4e5f6a7b"},"forType":{"$ref":"#/components/schemas/PresetForType"},"version":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Version number (1-based).","example":3},"changeDescription":{"type":"string","nullable":true,"description":"Description of the change that produced this version. Null for pre-versioning rows.","example":"Added premium-materials preset for high-end residential customers"},"createdAt":{"type":"string","format":"date-time","description":"When this version went live (ISO 8601).","example":"2026-04-12T16:30:00Z"},"isCurrent":{"type":"boolean","description":"Whether this is the currently-active version for the scope.","example":false}},"required":["presetId","forType","version","changeDescription","createdAt","isCurrent"]},"PresetsHistoryResponse":{"type":"object","properties":{"versions":{"type":"array","items":{"$ref":"#/components/schemas/PresetVersionSummary"},"description":"All versions for the requested scope, newest first."}},"required":["versions"]},"RestorePresetsRequest":{"type":"object","properties":{"forType":{"allOf":[{"$ref":"#/components/schemas/PresetForType"},{"default":"MATCHING","description":"Preset workflow type. Defaults to MATCHING."}]},"version":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"The version to restore. The restored content is written as a NEW version equal to current+1; intermediate versions are preserved in history.","example":3},"changeDescription":{"type":"string","maxLength":500,"description":"Human-readable description of why this restore is happening. Required when called from the internal admin UI; optional for external callers (defaults to \"Restored from v{version}\").","example":"Reverting accidental clearing of premium-materials presets"},"storeReference":{"type":"string","maxLength":200,"description":"Store/region identifier scoping the preset row.","example":"STORE-NYC-001"},"customerReference":{"type":"string","maxLength":200,"description":"Customer identifier scoping the preset row.","example":"CUST-12345"},"targetClientId":{"type":"string","format":"uuid"}},"required":["version"]}},"parameters":{}},"paths":{"/health":{"get":{"summary":"Health check","description":"Returns the health status of the Enterprise API server","tags":["Health"],"responses":{"200":{"description":"Server is healthy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthCheckResponse"}}}}}}},"/v1/oauth/token":{"post":{"summary":"Get OAuth access token","description":"Obtain an access token using the OAuth 2.0 Client Credentials grant type.\n\nThe access token should be included in subsequent API requests in the Authorization header:\n`Authorization: Bearer <access_token>`","tags":["OAuth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenRequest"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/OAuthTokenRequest"}}}},"responses":{"200":{"description":"Access token issued successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenResponse"}}}},"400":{"description":"Invalid request (bad grant_type, missing fields, invalid scope)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Invalid client credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/files/upload-urls":{"post":{"summary":"Request presigned upload URLs","description":"Request presigned S3 URLs for direct file uploads.\n\n**Supported File Types:**\n- **Documents:** PDF, DOC, DOCX, CSV, TXT, XLS, XLSX\n- **Images:** PNG, JPG, JPEG, JPE, JFIF, JIF, GIF, WebP, BMP, TIFF, TIF, SVG, ICO, HEIC, HEIF, AVIF, APNG\n- **Max file size:** 1GB per file\n- **Max files per request:** 100 files\n\n**Upload Process:**\n1. Call this endpoint with file metadata\n2. Use the returned `uploadUrl` with a PUT request to upload each file\n3. Include the `Content-Type` header matching the declared contentType\n4. File size must exactly match the declared `sizeBytes`\n\n**Example upload:**\n```bash\ncurl -X PUT \"<uploadUrl>\" \\\n  -H \"Content-Type: application/pdf\" \\\n  --data-binary \"@floor_plan.pdf\"\n```","tags":["Files"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadUrlsRequest"}}}},"responses":{"200":{"description":"Upload URLs generated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadUrlsResponse"}}}},"400":{"description":"Invalid request (bad file format, size exceeded, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions (missing files:manage scope)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/estimate":{"post":{"summary":"Create estimate from files","description":"Creates an estimate from uploaded files. The type parameter determines whether to process as a MATERIAL_LIST or BLUEPRINT.\nProcessing occurs asynchronously with results delivered via webhook.\n\n**Type-Specific Requirements:**\n- **BLUEPRINT**: Requires `fileIds` to be provided (blueprint files must be uploaded)\n- **MATERIAL_LIST**: Requires either `fileIds` or `prompt` (or both)\n\n**Workflow:**\n1. Upload files using `POST /v1/files/upload-urls`\n2. Submit file IDs to this endpoint for estimate creation\n3. Receive results via webhook or poll using `GET /v1/estimate/{estimateJobId}`\n\n**Response Delivery:**\n- Results are delivered via webhook to the provided `callbackUrl`\n- Alternatively, poll the job status using `GET /v1/estimate/{estimateJobId}`","tags":["Estimates"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EstimateRequest"}}}},"responses":{"201":{"description":"Estimate job created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EstimateResponse"}}}},"400":{"description":"Invalid request (validation error)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions. Required scope: 'materials:create' for MATERIAL_LIST or 'blueprints:create' for BLUEPRINT","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/estimate/match":{"post":{"summary":"Run material matching against a partner-supplied item list","description":"Runs catalog matching only — no file extraction, no estimate generation. Use this\nwhen you already have line items (id + product name + optional group/unit) and just want our\nsupplier-catalog matches.\n\nProcessing is asynchronous; results are delivered via webhook to `callbackUrl` in the same\nformat as `POST /v1/estimate`. The response webhook will carry `type: \"MATCHING\"` and each\nmatched row's `attributes.partnerItemId` echoes the input `id` so you can correlate.\n\n**Workflow:**\n1. POST your items to this endpoint\n2. Receive an immediate response with `estimateJobId`\n3. Receive a webhook at `callbackUrl` when matching completes\n4. Alternatively, poll `GET /v1/estimate/{estimateJobId}`","tags":["Estimates"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MatchingRequest"}}}},"responses":{"201":{"description":"Matching job created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MatchingResponse"}}}},"400":{"description":"Invalid request (validation error)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions. Required scope: 'materials:create'","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/estimate/{estimateJobId}":{"get":{"summary":"Get estimate job status","description":"Retrieves the current status and results of an estimate job.\nUse this endpoint to poll for job completion if webhook delivery fails.\n\n**Flexible ID Lookup:**\nThe `estimateJobId` parameter accepts either:\n- The UUID returned from estimate creation (e.g., `e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c`)\n- Your internal estimate ID provided during creation (e.g., `EST-12345`)\n\nThe system will automatically match against both the job ID and external ID fields.\n\n**Versioning:**\nEstimates support multiple versions. Each time an estimate is updated/regenerated, a new version is created.\n- Without `?version` parameter: Returns the current/latest version\n- With `?version=N` parameter: Returns a specific version (e.g., `?version=1` for the original)\n\n**Status Values:**\n- `PROCESSING`: Job is being processed\n- `COMPLETED`: Job finished successfully, results available\n- `FAILED`: Job failed, error information available\n\n**Results on a failed job:**\n`result` is normally present only for a COMPLETED job. The exception is\n`error.code = MARKUP_GENERATION_FAILED`, where the estimate itself succeeded and only its\ntakeoff markup deliverable could not be produced — the result is returned alongside the error,\nmatching the webhook for that job.","tags":["Estimates"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Estimate identifier - can be either the UUID job ID or your internal externalId","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"required":true,"description":"Estimate identifier - can be either the UUID job ID or your internal externalId","name":"estimateJobId","in":"path"},{"schema":{"type":"string","description":"Specific version number to retrieve. If omitted, returns the current/latest version.","example":"1"},"required":false,"description":"Specific version number to retrieve. If omitted, returns the current/latest version.","name":"version","in":"query"}],"responses":{"200":{"description":"Estimate job status and results","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EstimateGetResponse"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Estimate job not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/estimate/{estimateJobId}/files/{fileId}/download":{"get":{"summary":"Get a download URL for an estimate file","description":"Returns file metadata plus a short-lived presigned URL for downloading the file's bytes.\n\n**Which files:**\n- Takeoff deliverables announced in the estimate webhook's `takeoffFiles` array\n- The input files you uploaded for this estimate job\n\n**Flexible ID lookup:**\nThe `estimateJobId` parameter accepts either the UUID returned from estimate creation or your\ninternal externalId, matching the behaviour of `GET /v1/estimate/{estimateJobId}`.\n\n**Why a separate call:**\nThe presigned URL is deliberately not embedded in the webhook. Webhook payloads are signed when\nqueued and retried for up to six hours, so an embedded URL could arrive already expired. Call this\nendpoint when you are ready to download.\n\n**URL lifetime:**\nThe URL is valid for 15 minutes and is a bearer credential that cannot be revoked once issued — do\nnot log or share it. Request a fresh one whenever you need it; there is no limit beyond the usual\nrate limit.","tags":["Estimates"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Estimate identifier - either the UUID job ID or your internal externalId","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"required":true,"description":"Estimate identifier - either the UUID job ID or your internal externalId","name":"estimateJobId","in":"path"},{"schema":{"type":"string","description":"File identifier, as reported in the webhook payload","example":"a7c31c2b-9f4e-5d3a-8b1c-2e6f4a5b6c7d"},"required":true,"description":"File identifier, as reported in the webhook payload","name":"fileId","in":"path"}],"responses":{"200":{"description":"File metadata and a presigned download URL","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EstimateFileDownloadResponse"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Estimate job not found, or no such file belongs to it","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/estimate/{estimateJobId}/trade-report-summary":{"get":{"summary":"Get trade report summary","description":"Retrieves the LLM-condensed trade report summary for a blueprint estimate version.\n\nThis is an opt-in feature — summaries are only generated for clients with the trade report\nsummary feature enabled. Requires the `trade_reports:read` scope.\n\n**Flexible ID Lookup:** `estimateJobId` accepts either the UUID job ID or your internal externalId.\n\n**Versioning:** Without `?version`, returns the current/latest version's summary; with `?version=N`, a specific version.\n\nReturns `404` if the estimate has no summary (feature disabled, not a blueprint job, or not yet generated).","tags":["Estimates"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Estimate identifier - can be either the UUID job ID or your internal externalId","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"required":true,"description":"Estimate identifier - can be either the UUID job ID or your internal externalId","name":"estimateJobId","in":"path"},{"schema":{"type":"string","description":"Specific version number to retrieve. If omitted, returns the current/latest version.","example":"1"},"required":false,"description":"Specific version number to retrieve. If omitted, returns the current/latest version.","name":"version","in":"query"}],"responses":{"200":{"description":"Trade report summary","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TradeReportSummaryGetResponse"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Estimate job or trade report summary not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks/trade-report-summary":{"post":{"summary":"[Webhook] Trade report summary ready","description":"**This is not an API endpoint you call.** This documents the webhook payload sent to your `callbackUrl` when a blueprint estimate's trade report summary finishes generating.\n\nThis is an opt-in feature — the webhook only fires for clients with the trade report summary feature enabled.\n\n**Webhook Details:**\n- **Trigger:** A trade report summary is generated for a blueprint estimate version\n- **Destination:** Your `callbackUrl` provided in the estimate request\n- **Method:** POST\n- **Headers:**\n  - `Content-Type: application/json`\n  - `User-Agent: Handoff-Enterprise-Webhook/1.0`\n  - `X-Webhook-Attempt: <attempt_number>`\n  - `Authorization: Bearer <access_token>` (if outbound authentication is configured)\n- **Expected Response:** HTTP 2xx status code within 10 seconds\n- **Retry Policy:** Exponential backoff (1m, 5m, 15m, 1h, 6h)\n\n**Payload Structure:**\n- `event`: Always `trade_report_summary.ready`\n- `summary`: The LLM-condensed trade report summary in markdown (the deliverable)\n- `projectSummary`: Overall project summary, if available\n- `resultUrl`: URL to re-fetch the summary (`GET /v1/estimate/{id}/trade-report-summary`, requires the `trade_reports:read` scope)\n- `timestamp`: ISO 8601 timestamp of when the summary was generated\n\n**Security:**\n- Validate the `resultUrl` domain\n- Implement idempotency using `estimateJobId` + `version`","tags":["Estimates"],"servers":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TradeReportSummaryWebhookPayload"}}}},"responses":{"200":{"description":"Webhook received successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}}}}}}},"/v1/estimate/{estimateJobId}/update":{"post":{"summary":"Update an existing estimate","description":"Submits an update request for an existing estimate job. Creates a new version with the provided prompt instructions.\n\n**Requirements:**\n- The initial estimate (version 1) must be completed before updates are accepted\n- If a previous update is still in progress, it will be canceled and superseded by this request\n\n**File Handling:**\n- New file IDs are additive — they are added to the job's existing files\n- Files added in this update are tracked on the new version\n\n**Base Estimate Items:**\n- If `items` array is provided, it will be used as the base for the update\n- If not provided, the most recent completed version's items will be used\n\n**Optional Job Updates:**\n- `callbackUrl` and `metadata` can be updated on the job (other job fields are immutable)","tags":["Estimates"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"The estimate job ID or external ID","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"required":true,"description":"The estimate job ID or external ID","name":"estimateJobId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EstimateUpdateRequest"}}}},"responses":{"201":{"description":"Update version created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EstimateUpdateResponse"}}}},"400":{"description":"Invalid request (validation error)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Estimate job not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Initial estimate must complete before updates can be submitted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/estimate/{estimateJobId}/stream":{"get":{"summary":"Stream estimate update progress (SSE)","description":"Opens a [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events)\nstream of live progress for an asynchronous estimate **update**, from file extraction through the AI\nedit to material matching.\n\nStreaming is a **read-only side-channel** — consuming it is optional. The authoritative result is still\ndelivered by the completion webhook and `GET /v1/estimate/{estimateJobId}`. The `streamUrl` returned by\n`POST /v1/estimate/{estimateJobId}/update` points here.\n\n**Event frames**\nEach SSE frame is `id`, `event`, and `data`. `id` is the resume cursor, `event` is the event\n`type`, and `data` is a JSON `EstimateStreamEvent`. Comment lines (`: ping`) are heartbeats — ignore them.\n\n**Event types**\n- `phase` — lifecycle stage: `extracting` → `editing` → `finalizing` → `matching` (`extracting` only when the update includes files still being processed; prompt-only updates start at `editing`).\n- `status` — a tool/step started or finished.\n- `message` — assistant text (incremental `delta`s and/or a `final` summary).\n- `estimate_change` — a row was added / updated / removed (`change.op`).\n- `matching_progress` — `matched` of `total` materials matched.\n- `done` / `error` — terminal; the server then closes the connection. `done` carries a `resultUrl`.\n\n**Versioning**\nOmit `version` to stream the latest version (the one the most recent update created), or pass `?version=N`.\n\n**Reconnecting**\nThe stream is resumable: reconnect and send the last `id` you received in the `Last-Event-ID` header to\nresume with no gaps or duplicates. Without it, recent buffered events (~1h) are replayed so a late\nsubscriber catches up. Each connection is capped (~110s) and closed with an `event: reconnect`; just\nreconnect with the cursor. This makes long (15+ minute) runs resilient to proxy/CDN idle timeouts.\n\n**Note:** Browsers' native `EventSource` cannot send an `Authorization` header — use `fetch` with a\nstreaming body reader (or an SSE client that supports custom headers).","tags":["Estimates"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Estimate identifier — either the UUID job ID or your internal externalId.","example":"e1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"},"required":true,"description":"Estimate identifier — either the UUID job ID or your internal externalId.","name":"estimateJobId","in":"path"},{"schema":{"type":"string","description":"Specific version to stream. If omitted, streams the current/latest version.","example":"2"},"required":false,"description":"Specific version to stream. If omitted, streams the current/latest version.","name":"version","in":"query"},{"schema":{"type":"string","description":"Resume cursor — the `id` of the last event received. Omit to replay from the buffer.","example":"1720448400000-0"},"required":false,"description":"Resume cursor — the `id` of the last event received. Omit to replay from the buffer.","name":"Last-Event-ID","in":"header"}],"responses":{"200":{"description":"An open `text/event-stream`. Each event's `data` field is an `EstimateStreamEvent`. The stream stays open until a terminal `done`/`error` event, then closes.","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/EstimateStreamEvent"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Estimate job not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks/estimate":{"post":{"summary":"[Webhook] Estimate updated","description":"**This is not an API endpoint you call.** This documents the webhook payload sent to your `callbackUrl` whenever an estimate's status changes.\n\n**Webhook Details:**\n- **Trigger:** Any estimate status change (COMPLETED, FAILED, HUMAN_REVIEW, PROCESSING)\n- **Destination:** Your `callbackUrl` provided in the estimate request\n- **Method:** POST\n- **Headers:**\n  - `Content-Type: application/json`\n  - `User-Agent: Handoff-Enterprise-Webhook/1.0`\n  - `X-Webhook-Attempt: <attempt_number>`\n  - `Authorization: Bearer <access_token>` (if outbound authentication is configured)\n- **Expected Response:** HTTP 2xx status code within 10 seconds\n- **Retry Policy:** Exponential backoff (1m, 5m, 15m, 1h, 6h)\n\n**Payload Structure:**\n- `event`: Always `estimate.updated`\n- `status`: The current status (COMPLETED, FAILED, HUMAN_REVIEW, PROCESSING)\n- `result`: Present when status is COMPLETED or HUMAN_REVIEW, null otherwise\n- `error`: Present when status is FAILED, null otherwise\n- `files`: The input files you uploaded for this estimate\n- `takeoffFiles`: Generated takeoff deliverables ready to download. Non-empty only on COMPLETED/HUMAN_REVIEW for blueprint estimates\n- `timestamp`: ISO 8601 timestamp of the status change\n\n**Status Meanings:**\n- `COMPLETED`: Estimate finished successfully, `result` contains the estimate data\n- `FAILED`: Estimate processing failed, `error` contains error details\n- `HUMAN_REVIEW`: Estimate requires human review\n- `PROCESSING`: Estimate files were processed and the estimate is being generated\n\n**Takeoff Deliverables:**\nFor blueprint estimates on the takeoff delivery contract, a COMPLETED or HUMAN_REVIEW webhook always\ncarries exactly one TAKEOFF_MARKUP entry in `takeoffFiles`, resolved before the success status is\nrecorded. Entries are discriminated on `status`:\n- `READY` — full metadata with a stable `id` (unchanged across webhook retries) and a `checksum`\n  for integrity verification; fetch the bytes via\n  `GET /v1/estimate/{estimateJobId}/files/{fileId}/download`\n- `FAILED` — the file could not be produced and will not arrive; the entry carries only\n  `fileType` and `error: { code, message }`. **The job itself still succeeds** — act on the\n  estimate without the attachment.\n\nA presigned URL is deliberately not embedded here because this payload is signed when queued and\nretried for up to six hours, so an embedded URL could arrive already expired. The same\n`takeoffFiles` array is returned by `GET /v1/estimate/{estimateJobId}`, so a missed webhook never\nstrands the deliverable.\n\n**Error Codes (when status is FAILED):**\n- `FILE_PROCESSING_FAILED`: One or more files failed to process (details contains failed file IDs)\n- `FILE_EXTRACTION_NO_ITEMS`: File extraction succeeded but no items were found in the provided files (details contains processed file IDs)\n- `FILE_NO_CONTENT`: The uploaded file(s) contain no readable content (e.g. a blank page) and the request carried no prompt\n- `CONFIGURATION_ERROR`: Client configuration is missing or invalid\n- `AI_ESTIMATE_GENERATION_ERROR`: AI estimate generation failed\n- `MARKUP_GENERATION_FAILED`: Never a job-level status code — it appears only inside a `takeoffFiles` entry's `error` (see Takeoff Deliverables above)\n- `INTERNAL_ERROR`: Internal system error\n\n**Versioning:**\nEach time an estimate is updated/regenerated, a new version is created and a webhook is sent with the new version number.\nUse `?version=N` on the resultUrl to fetch specific historical versions.\n\n**Security:**\n- Validate `resultUrl` domain is `api.handoff.ai`\n- Fetch full results from `resultUrl` using your access token\n- Implement idempotency using `estimateJobId` + `version`","tags":["Estimates"],"servers":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EstimateWebhookPayload"}}}},"responses":{"200":{"description":"Webhook received successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}}}}}}},"/v1/webhook/oauth":{"put":{"operationId":"setWebhookOauth","summary":"Set webhook OAuth credentials","description":"Configure OAuth 2.0 credentials for authenticating webhook callbacks to your endpoints.\n\nWhen you provide a `callbackUrl` in estimate requests, we will:\n1. Request an access token from your OAuth server using these credentials\n2. Include the token in the `Authorization: Bearer <token>` header\n\n**Security:**\n- Client secrets are stored securely encrypted\n- Tokens are cached and automatically refreshed\n- Only client_credentials grant type is supported","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetWebhookOauthRequest"}}}},"responses":{"200":{"description":"OAuth credentials configured successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookOauthResponse"}}}},"400":{"description":"Invalid request (validation error)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions. Required scope: webhooks:manage","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"operationId":"getWebhookOauth","summary":"Get webhook OAuth credentials","description":"Retrieve the current OAuth 2.0 credentials configured for webhook authentication.\n\n**Note:** The client secret is never returned - only the non-sensitive configuration is shown.","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"OAuth credentials retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookOauthResponse"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions. Required scope: webhooks:manage","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"OAuth credentials not configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteWebhookOauth","summary":"Delete webhook OAuth credentials","description":"Remove OAuth 2.0 credentials for webhook authentication.\n\n**Warning:** This will:\n- Delete the client secret from secure storage\n- Disable OAuth authentication for webhooks\n- Future webhooks will be sent without OAuth authentication","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"OAuth credentials deleted successfully"},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions. Required scope: webhooks:manage","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"OAuth credentials not configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhook/hmac":{"put":{"operationId":"setWebhookHmac","summary":"Set webhook HMAC secret","description":"Configure HMAC signing for webhook payload verification.\n\nWhen enabled, all webhooks will include:\n- `X-Handoff-Signature`: HMAC-SHA256 signature (format: `sha256=<hex>`)\n- `X-Handoff-Timestamp`: ISO 8601 timestamp used in signature\n\n**Verification:**\n```\nsignature = HMAC-SHA256(timestamp + \".\" + payload, secret)\n```\n\n**Security:**\n- Secret is stored securely encrypted\n- Use a cryptographically random string of at least 32 characters","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetWebhookHmacRequest"}}}},"responses":{"200":{"description":"HMAC secret configured successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookHmacResponse"}}}},"400":{"description":"Invalid request (validation error)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions. Required scope: webhooks:manage","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"operationId":"getWebhookHmac","summary":"Get webhook HMAC status","description":"Retrieve the current HMAC signing configuration for webhooks.\n\n**Note:** The HMAC secret is never returned - only the configuration status is shown.","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"HMAC configuration retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookHmacResponse"}}}},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions. Required scope: webhooks:manage","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"HMAC signing not configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteWebhookHmac","summary":"Delete webhook HMAC secret","description":"Remove HMAC signing configuration for webhooks.\n\n**Warning:** This will:\n- Delete the HMAC secret from secure storage\n- Disable HMAC signing for webhooks\n- Future webhooks will not include signature headers","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"HMAC secret deleted successfully"},"401":{"description":"Missing or invalid authentication token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient permissions. Required scope: webhooks:manage","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"HMAC signing not configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/presets":{"post":{"summary":"Set AI presets","description":"Configures AI presets for matching, estimate, takeoff, or trade-report generation. Presets can be:\n- **Global**: Applied to all jobs of the selected workflow type (no storeReference or customerReference)\n- **Store-level**: Applied to jobs of the selected workflow type for a specific store/region (storeReference only)\n- **Customer-level**: Applied to jobs of the selected workflow type for a specific customer (customerReference, optionally with storeReference)\n\n**Hierarchy:** Customer-specific → Store-specific → Global\n\n**Note:** Setting presets for an existing scope will replace the previous presets entirely.","tags":["Presets"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPresetsRequest"}}}},"responses":{"200":{"description":"Presets successfully configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetsResponse"}}}},"400":{"description":"Invalid request (validation error)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized (missing or invalid token)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden (insufficient permissions)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"summary":"Get AI presets","description":"Retrieves configured AI presets. Can query:\n- **Global presets**: No query parameters\n- **Store-level presets**: Provide storeReference only\n- **Customer-level presets**: Provide customerReference (optionally with storeReference)\n\n**Note:** Returns the exact preset for the specified scope. Does not apply hierarchy/fallback logic.","tags":["Presets"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"allOf":[{"$ref":"#/components/schemas/PresetForType"},{"default":"MATCHING","description":"Preset workflow type. Defaults to MATCHING."}]},"required":false,"description":"Preset workflow type. Defaults to MATCHING.","name":"forType","in":"query"},{"schema":{"type":"string","maxLength":200,"description":"Store/region identifier. If provided, returns presets for this store.","example":"STORE-NYC-001"},"required":false,"description":"Store/region identifier. If provided, returns presets for this store.","name":"storeReference","in":"query"},{"schema":{"type":"string","maxLength":200,"description":"Customer identifier. If provided, returns presets for this customer.","example":"CUST-12345"},"required":false,"description":"Customer identifier. If provided, returns presets for this customer.","name":"customerReference","in":"query"},{"schema":{"type":"string","format":"uuid"},"required":false,"name":"targetClientId","in":"query"}],"responses":{"200":{"description":"Presets retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetsResponse"}}}},"401":{"description":"Unauthorized (missing or invalid token)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden (insufficient permissions)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Presets not found for the requested scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/presets/history":{"get":{"summary":"List preset versions","description":"Lists every version of the preset row for the requested scope, newest first.\nIncludes the current version (from the live table) plus every prior version archived in the temporal history table.\n\nUse this to render a version timeline. Each entry omits the full `presets` array — fetch `/v1/presets/versions/{version}` for full content.","tags":["Presets"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"allOf":[{"$ref":"#/components/schemas/PresetForType"},{"default":"MATCHING","description":"Preset workflow type. Defaults to MATCHING."}]},"required":false,"description":"Preset workflow type. Defaults to MATCHING.","name":"forType","in":"query"},{"schema":{"type":"string","maxLength":200,"description":"Store/region identifier. If provided, returns history for this store's presets.","example":"STORE-NYC-001"},"required":false,"description":"Store/region identifier. If provided, returns history for this store's presets.","name":"storeReference","in":"query"},{"schema":{"type":"string","maxLength":200,"description":"Customer identifier. If provided, returns history for this customer's presets.","example":"CUST-12345"},"required":false,"description":"Customer identifier. If provided, returns history for this customer's presets.","name":"customerReference","in":"query"},{"schema":{"type":"string","format":"uuid"},"required":false,"name":"targetClientId","in":"query"}],"responses":{"200":{"description":"Version list returned successfully (may be empty if no presets exist for the scope).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetsHistoryResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/presets/versions/{version}":{"get":{"summary":"Get a specific preset version","description":"Returns the full content of a specific version of the preset row for the requested scope. Pulls from the live table if the version is current, otherwise from the temporal history table.","tags":["Presets"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Version number to fetch.","example":3},"required":true,"description":"Version number to fetch.","name":"version","in":"path"},{"schema":{"allOf":[{"$ref":"#/components/schemas/PresetForType"},{"default":"MATCHING","description":"Preset workflow type. Defaults to MATCHING."}]},"required":false,"description":"Preset workflow type. Defaults to MATCHING.","name":"forType","in":"query"},{"schema":{"type":"string","maxLength":200,"description":"Store/region identifier scoping the preset row.","example":"STORE-NYC-001"},"required":false,"description":"Store/region identifier scoping the preset row.","name":"storeReference","in":"query"},{"schema":{"type":"string","maxLength":200,"description":"Customer identifier scoping the preset row.","example":"CUST-12345"},"required":false,"description":"Customer identifier scoping the preset row.","name":"customerReference","in":"query"},{"schema":{"type":"string","format":"uuid"},"required":false,"name":"targetClientId","in":"query"}],"responses":{"200":{"description":"Version returned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetsResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Version not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/presets/restore":{"post":{"summary":"Restore an earlier preset version","description":"Replays an earlier version's preset content as a NEW version on top of the current state.\n\nFor example, restoring v3 while at v8 produces v9 whose content equals v3's. Intermediate versions (v4-v8) remain queryable through the history endpoints — restore never destroys history.","tags":["Presets"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestorePresetsRequest"}}}},"responses":{"200":{"description":"Restore applied; returns the new current version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetsResponse"}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"The requested version does not exist for the scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}