Offers API Documentation

Manage offers from contractors to clients with acceptance workflow

Offers Management API

Complete offer lifecycle management with Inquiry integration and acceptance workflow

Overview

The Offers API manages the complete lifecycle of contractor offers linked to Inquiries. Multiple contractors can submit offers for the same inquiry. When a client accepts one offer, all other offers for that inquiry are automatically expired and all contractors are notified of the outcome.

Inquiry-Based Workflow

1. Client posts an inquiry
2. Multiple contractors submit offers (linked to inquiry_id)
3. Client reviews all offers
4. Client accepts one offer → All others auto-expire
5. All contractors receive email notifications

Core Features

  • Inquiry-based linking
  • Multi-contractor support
  • Acceptance workflow
  • Auto-expire competing offers
  • Email notifications

Offer Status

  • Draft - Not yet sent
  • Sent - Delivered to client
  • Viewed - Client has seen it
  • Accepted - Client accepted
  • Rejected - Client rejected
  • Expired - Another offer accepted

Offer Line Types

  • Product - Materials/products
  • Labor - Work hours
  • Travel - Travel costs
  • Other - Misc. costs
  • Auto-calc VAT & totals

Notifications

  • New offer sent to client
  • Acceptance notification
  • Rejection notification
  • Expiration notification

Attachments

  • Upload files per offer
  • PDF, images, Office docs
  • Max 10 MB per file
  • Private local storage
  • Secure download endpoint
Method Endpoint Description Requirements
POST /api/offers/send Submit a new offer from contractor to client (linked to an inquiry)
  • inquiry_id (required) – ID of the inquiry this offer responds to
  • contractor_id (required) – Contractor user ID (from UserHub)
  • client_id (required) – Client user ID (from UserHub)
  • users (injected by UserHub) – Map of user objects keyed by user ID; contractor & client snapshots are built from this
  • lines (required) – Array of offer lines with type, description, quantity, unit_price, vat_percentage
  • currency (optional) – Currency code (default: EUR)
  • valid_until (optional) – Offer validity date (default: 30 days)
  • description (optional) – Additional offer description
  • discount_amount (optional) – Overall offer discount (default: 0)
  • Note: subtotal, tax_amount, and total are auto-calculated from lines
GET /api/offers/list?inquiry_id={id} List all offers for a specific inquiry
  • inquiry_id (required) – Inquiry ID to filter offers
  • status (optional) – Filter by status (draft, sent, viewed, accepted, rejected, expired)
GET /api/offers/client?client_id={id} List all offers visible to a specific client
  • client_id (required) – Client user ID
  • status (optional) – Filter by status
GET /api/offers/{id} Get details of a specific offer (auto-marks as viewed)
  • id (required) – Offer ID
POST /api/offers/{id}/accept Accept an offer (expires all other offers for the same inquiry)
  • id (required) – Offer ID to accept
  • message (optional) – Optional message to winning contractor
POST /api/offers/{id}/reject Reject an offer
  • id (required) – Offer ID to reject
  • reason (optional) – Optional rejection reason
GET /api/offers/{id}/attachments List all file attachments for an offer
  • id (required) – Offer ID
POST /api/offers/{id}/attachments Upload a file attachment to an offer (multipart/form-data)
  • id (required) – Offer ID
  • file (required) – File to upload (PDF, JPEG, PNG, GIF, WebP, DOC, DOCX, XLS, XLSX, TXT; max 10 MB)
  • uploaded_by (optional) – User ID of the uploader
GET /api/offers/{id}/attachments/{attachmentId}/download Download an attachment file
  • id (required) – Offer ID
  • attachmentId (required) – Attachment ID
DELETE /api/offers/{id}/attachments/{attachmentId} Delete an attachment (removes file from disk)
  • id (required) – Offer ID
  • attachmentId (required) – Attachment ID

Request Examples

1. Send Offer for an Inquiry

POST /api/offers/send

(with Offer Lines)

POST /api/offers/send

{ "inquiry_id": 5, "contractor_id": 42, "client_id": 17, "users": { "42": { "id": 42, "name": "ABC Construction", "email": "contractor@example.com", "phone": "+31612345678" }, "17": { "id": 17, "name": "John Doe", "email": "client@example.com" } }, "description": "Complete kitchen renovation including materials and labor", "currency": "EUR", "valid_until": "2026-02-28", "discount_amount": 0, "lines": [ { "description": "Kitchen cabinets - Premium oak", "notes": "Custom-made cabinets, including installation hardware", "line_type": "product", "quantity": 12, "unit": "pieces", "unit_price": 450.00, "discount_percentage": 10, "vat_percentage": 21, "product_id": 101, "sort_order": 1 }, { "description": "Countertop - Granite", "line_type": "product", "quantity": 5, "unit": "m²", "unit_price": 280.00, "vat_percentage": 21, "product_id": 102, "sort_order": 2 }, { "description": "Installation and assembly", "line_type": "labor", "quantity": 40, "unit": "hours", "unit_price": 65.00, "vat_percentage": 21, "sort_order": 3 }, { "description": "Travel costs", "line_type": "travel", "quantity": 120, "unit": "km", "unit_price": 0.45, "vat_percentage": 21, "sort_order": 4 }, { "description": "Waste disposal", "line_type": "other", "quantity": 1, "unit": "service", "unit_price": 250.00, "vat_percentage": 21, "sort_order": 5 } ] }

Note: Each line automatically calculates discount_amount, subtotal, vat_amount, and total based on quantity, unit_price, discount_percentage, and vat_percentage. The offer's overall subtotal, tax_amount, and total are calculated from all lines.

Response - Offer Sent (201 Created)

{ "success": true, "message": "Offer sent successfully", "data": { "offer_id": 1, "offer_number": "OFF-2026-0001", "status": "sent", "total": 11274.44, "lines_count": 5 } }

Get Offer Details with Lines

GET /api/offers/{id}

{ "success": true, "data": { "id": 1, "offer_number": "OFF-2026-0001", "inquiry_id": 5, "contractor_id": 42, "client_id": 17, "contractor_snapshot": { "name": "ABC Construction", "email": "contractor@example.com" }, "client_snapshot": { "name": "John Doe", "email": "client@example.com" }, "status": "sent", "currency": "EUR", "subtotal": 9764.00, "tax_amount": 2050.44, "discount_amount": 0.00, "total": 11814.44, "lines": [ { "id": 1, "description": "Kitchen cabinets - Premium oak", "line_type": "product", "quantity": 12.00, "unit": "pieces", "unit_price": 450.00, "discount_percentage": 10.00, "subtotal": 4860.00, "vat_percentage": 21.00, "vat_amount": 1020.60, "total": 5880.60 }, { "id": 2, "description": "Installation and assembly", "line_type": "labor", "quantity": 40.00, "unit": "hours", "unit_price": 65.00, "subtotal": 2600.00, "vat_amount": 546.00, "total": 3146.00 }, { "id": 3, "description": "Travel costs", "line_type": "travel", "quantity": 120.00, "unit": "km", "unit_price": 0.45, "subtotal": 54.00, "vat_amount": 11.34, "total": 65.34 } ], "totals_by_type": { "product": 7574.60, "labor": 3872.00, "travel": 65.34, "other": 302.50 } } }

2. List All Offers for an Inquiry

GET /api/offers/list?inquiry_id=5

{ "success": true, "count": 2, "data": [ { "id": 1, "offer_number": "OFF-2026-0001", "inquiry_id": 5, "contractor_id": 42, "total": 11274.44, "currency": "EUR", "status": "viewed", "lines_count": 5, "sent_at": "2026-01-29T12:00:00Z" }, { "id": 2, "offer_number": "OFF-2026-0002", "inquiry_id": 5, "contractor_id": 55, "total": 8500.00, "currency": "EUR", "status": "sent", "lines_count": 3, "sent_at": "2026-01-29T14:30:00Z" } ] }

3. Accept an Offer

POST /api/offers/{id}/accept

{ "message": "Great proposal! Looking forward to working with you." }

Response:

{ "success": true, "message": "Offer accepted successfully", "data": { "accepted_offer": { "id": "9d1f3c2a-5b8e-4f7d-a1c3-6e9b2d4f8a1c", "status": "accepted", "accepted_at": "2025-10-30T15:00:00Z" }, "expired_offers_count": 2, "notifications_sent": { "accepted": "notif-123", "expired": ["notif-124", "notif-125"] } } }

Note: Accepting an offer automatically expires all other offers for the same inquiry and sends email notifications to all contractors. User data (names, emails) is resolved by the UserHub proxy and returned in the top-level users map in the response.

4. Reject an Offer

POST /api/offers/{id}/reject

{ "reason": "Price exceeds our budget" }

Response:

{ "success": true, "message": "Offer rejected successfully", "data": { "id": "8c2e4b3d-6a9f-5e8c-b2d4-7f0a3c5e9b2d", "status": "rejected", "rejection_reason": "Price exceeds our budget", "rejected_at": "2025-10-30T15:30:00Z" } }

Email Notifications

Contractors receive different email notifications based on offer status:

✓ Acceptance Email

Congratulations! Your offer #OFF-2025-001 has been accepted by John Doe. Message from client: Great proposal! Looking forward to working with you. Offer Details: Total Amount: EUR 9982.50 Accepted at: 30/10/2025 15:00

⊗ Expiration Email

Your offer #OFF-2025-002 for John Doe has expired as another contractor's offer was accepted. Thank you for your interest. We hope to work with you on future projects.

✗ Rejection Email

Your offer #OFF-2025-003 for John Doe has been rejected. Reason: Price exceeds our budget Thank you for your interest. We hope to work with you on future projects.
Best regards, ABC Construction contractor@example.com

Error Response (500)

{ "status": "error", "message": "Failed to send offer: Notification service is not available" }

Validation Error (422)

{ "message": "The given data was invalid.", "errors": { "client_email": [ "The client email must be a valid email address." ], "items": [ "At least one item is required" ] } }