# Quote requests (RFQs)

A quote request (RFQ) asks one or more vendors for prices on items an estimate buys: materials, rented equipment, trucking and subcontractor work. Each vendor gets a private link to a quote page (no account needed) where they enter prices; you can also record a quote they gave by phone. Accepting a quote saves it as the vendor's price and locks it onto the estimate's lines, so the bid re-prices.

**The loop.**

1. `GET /v1/jobs/{job_id}/procurement` shows what the job's estimate buys and which vendors are set up to quote each item.
2. `POST /v1/estimates/{estimate_id}/rfqs` (or `POST /v1/jobs/{job_id}/rfqs`) creates a **draft**. Nothing is sent.
3. `POST /v1/rfqs/{id}/send` **emails each vendor** their quote link. Status becomes `sent`.
4. Quotes come back on the vendor's page, or you record them with `POST /v1/rfqs/{id}/responses`. Status becomes `answered`.
5. `GET /v1/rfqs/{id}/leveling` compares them; `POST /v1/rfq-responses/{id}/accept` locks the winner into the bid. Status becomes `closed` once every item has an accepted price.
6. Optionally, `POST /v1/rfq-responses/{id}/notify-accepted` **emails the winning vendor**.

Vendors (suppliers, rental houses and subcontractor firms) are managed with [`/v1/catalog/vendors`](catalog.md). A vendor can be asked to quote an item when it supplies that kind of item, already has a price for it, or is a subcontractor (for sub work).

Writes act as the user who created the API key, with that user's estimating role (owner, admin or estimator). A member's key never acts as anyone else. Requests email from that user's connected mailbox when they have one ([Authentication](authentication.md)).

!!! warning
    Four calls on this page reach people outside your workspace: `POST /v1/rfqs/{id}/send` and `POST /v1/rfqs/send-digest` email (or text) vendors, `POST /v1/rfqs/{id}/text-link` texts one, and `POST /v1/rfq-responses/{id}/notify-accepted` emails one. Confirm before you call them, especially from an AI assistant. Everything else only changes your workspace.

!!! note
    Partner keys that act on behalf of several workspaces must also pass `firm_id` (query string, or JSON body on writes).

## The quote request object

| Field | Type | Description |
|---|---|---|
| `id` | integer | |
| `estimate_id`, `job_id` | integer | Where it belongs. |
| `title` | string | |
| `status` | string | `draft`, `sent`, `answered` or `closed`. |
| `bid_due_on` | string or null | `YYYY-MM-DD`. Quotes are accepted through the end of this day. |
| `sent_at` | string or null | Last send. |
| `items` | array | What is being priced: `type` (`material`, `equipment`, `trucking`, `subcontractor`), `catalog_item_id`, `name`, `quantity`, `unit`; `line_item_id` + `trade` for subcontractor work quoted for one estimate line; `vendor_ids` when each item goes to particular vendors. |
| `vendor_ids` | array | Everyone asked. |
| `response_count` | integer | Quotes received. |
| `scope_html` | string or null | The scope note vendors read. Single request only. |
| `recipients` | array | Per vendor: `email`, `phone`, `sent_at`, `delivered`, `viewed_at`, `responded_at`, `locked_after_submit` (they submitted, so their page is read-only). Single request only. |
| `responses` | array | Every quote (below). Single request only. |
| `created_at`, `updated_at` | string | |

**A quote** (`responses[]`): `id` (use it to accept), `vendor_id`, `vendor_name`, `price` and `unit` (single-item requests), `item_prices` (multi-item: `type`, `catalog_item_id`, `price`, `unit`, `notes`), `notes`, `submitted_via` (`office` or `link`), `has_bid_pdf`, `received_at`, `accepted_at`, `accepted_items`.

## Read

`GET /v1/rfqs` lists requests, newest first. Filters: `estimate_id`, `job_id`, `status`, `updated_after` (then oldest change first), `limit`, `offset`. **Permission:** Read jobs.

`GET /v1/rfqs/{id}` returns one with recipients and quotes.

`GET /v1/estimates/{estimate_id}/rfqs` lists an estimate's requests plus `quotes_received`: every quote back, grouped by item and ranked cheapest first (`rank`, `price`, `unit`, `response_id`, `accepted`, `used_in_estimate`, `vendor_notified`). `mixed_units: true` means the quotes use different units, so the ranking isn't comparable.

`GET /v1/rfqs/{id}/leveling` lays one request out side by side: items down, vendors across, each vendor's newest price, the `cheapest`, `outlier` (more than 20% over the cheapest), `missing` and the `accepted` quote. The key's user needs an estimating role.

```bash
curl "https://trutec.ai/api/v1/estimates/9840/rfqs" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 812, "estimate_id": 9840, "job_id": 70412, "title": "RFQ — Northgate Plaza",
      "status": "answered", "bid_due_on": "2026-10-20", "sent_at": "2026-10-13T16:02:11Z",
      "items": [{"type": "material", "catalog_item_id": 311, "name": "Asphalt sealer",
                 "quantity": 1450.0, "unit": "gal", "vendor_ids": [55, 61]}],
      "vendor_ids": [55, 61], "response_count": 2,
      "created_at": "2026-10-13T15:58:40Z", "updated_at": "2026-10-14T09:12:30Z"
    }
  ],
  "pagination": {"total": 1, "limit": 50, "offset": 0},
  "quotes_received": [
    {
      "type": "material", "catalog_item_id": 311, "name": "Asphalt sealer", "quantity": 1450.0, "unit": "gal",
      "has_accepted_quote": false, "mixed_units": false,
      "quotes": [
        {"rank": 1, "rfq_id": 812, "response_id": 4410, "vendor_id": 61, "vendor_name": "Valley Coatings Supply",
         "price": 4.62, "unit": "gal", "accepted": false, "used_in_estimate": false, "vendor_notified": false},
        {"rank": 2, "rfq_id": 812, "response_id": 4402, "vendor_id": 55, "vendor_name": "Ridge Materials",
         "price": 4.85, "unit": "gal", "accepted": false, "used_in_estimate": false, "vendor_notified": false}
      ]
    }
  ]
}
```

### What a job can request

`GET /v1/jobs/{job_id}/procurement`

**Permission:** Read jobs (the key's user needs pricing access and an estimating role of owner, admin or estimator)

Returns the job's `estimates`; `quotable_items` (what the chosen estimate buys, with summed `quantity`, the `line_item_ids` using it and the `vendor_ids` set up to quote it); `catalog` (up to 50 matches for `type` / `q`, each with its eligible `vendor_ids`); the `vendors` book; and the job's `rfqs`. Query: `estimate_id` (default: the newest), `type`, `q`.

## Create a quote request

`POST /v1/estimates/{estimate_id}/rfqs`

**Permission:** Write jobs (role: owner, admin or estimator)

Creates a **draft**. Nothing is sent.

| Name | Type | Required | Description |
|---|---|---|---|
| `items` | array | Yes | 1–100 items: `type`, `catalog_item_id`, optional `quantity`, `unit`, `line_item_id` (subcontractor items only: quote that estimate line on its own, with its quantity and internal scope), `vendor_ids`. |
| `vendor_ids` | array | No | The vendors to ask. Defaults to everyone named on the items. |
| `title` | string | No | Defaults to "RFQ — *estimate name*". |
| `scope_html` | string | No | The scope note. `{{vendor_name}}`, `{{job_name}}`, `{{address}}` and `{{bid_due}}` fill in per vendor. Defaults to your saved request email. |
| `bid_due_on` | string or null | No | `YYYY-MM-DD`. Leave it out for your workspace's default due date; `null` for none. |

Give `vendor_ids` on every item or on none. Items with `vendor_ids` go only to those vendors; without, every vendor sees every item. Each vendor must be set up to quote the items it gets.

```bash
curl -X POST "https://trutec.ai/api/v1/estimates/9840/rfqs" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"type": "material", "catalog_item_id": 311, "quantity": 1450, "unit": "gal",
                  "vendor_ids": [55, 61]}],
       "bid_due_on": "2026-10-20"}'
```

Returns `201` with the request.

**Errors:** `400` `items must be a list of 1 to 100 items to quote`, `Give vendor_ids on every item, or on none of them`, and the app's reason when a vendor isn't set up to quote an item. `404` `Estimate not found`, `Catalog item … not found`, `Vendor not found: …`, `Line item not found`.

### From a job

`POST /v1/jobs/{job_id}/rfqs`

Same body, plus `estimate_id` (default: the job's newest estimate; a job with none gets a blank draft estimate) and `client_request_id` (a UUID). With `client_request_id`, repeating the call with the same body returns the first request with `200` and `"replayed": true`; reusing it for a different body is `409`. The request is logged on the job. Returns `201` with `data`, `estimate_id` and `replayed`.

## Send

### Email the vendors

`POST /v1/rfqs/{id}/send`

**Permission:** Write jobs (role: owner, admin or estimator)

**Emails each vendor** on the request their quote link, from the key user's connected mailbox when they have one (replies go back to them). Vendors with no email but a cell number are texted the link when your workspace has texting set up. Marks the request `sent` and logs who sent it (the quote page shows them as the contact).

| Name | Type | Description |
|---|---|---|
| `subject` | string | Defaults to your saved request email's subject, else "Quote request — *title* — *company*". |
| `message_html` | string | Replaces the scope note. |

Returns `data` (the request), `sent` (per vendor: `channel` `email` or `text`, `to`, `delivered`, `quote_url`), `skipped` (`vendor_id`, `reason`: no email on file, no items assigned) and `sent_from`.

### One email per vendor for several requests

`POST /v1/rfqs/send-digest` with `rfq_ids` (up to 50) and optional `subject`. **Emails each vendor once**, with a section and link per request they are on. Returns `data` (the requests), `sent` and `skipped`.

### Text one vendor

`POST /v1/rfqs/{id}/text-link` with `vendor_id` and optional `phone` (saved on the vendor first). **Texts** that vendor their link. Returns `phone` and `quote_url`.

**Errors:** `400` `That phone number does not look right.`, `No cell number on file for this vendor — add one first.`, `Texting is not enabled on this workspace yet.` `502` when the text could not be sent.

## Quotes

### Record a quote

`POST /v1/rfqs/{id}/responses`

**Permission:** Write jobs (role: owner, admin or estimator)

For a quote a vendor gave by phone or email. Multi-item requests take `item_prices`; single-item requests take `price`.

| Name | Type | Description |
|---|---|---|
| `vendor_id` | integer | Required. |
| `item_prices` | array | `type`, `catalog_item_id`, `price`, optional `unit`, `notes`. Items not on the request (or not assigned to that vendor) are dropped. |
| `price` | number | Single-item requests. |
| `unit`, `notes` | string | |

Returns `201` with the quote.

### Accept a quote

`POST /v1/rfq-responses/{response_id}/accept`

**Permission:** Write jobs (role: owner, admin or estimator)

The quoted price becomes the vendor's saved price for the item and is locked onto the estimate's matching lines, which re-price. A subcontractor quote for one estimate line is locked on that line only. Another vendor's accepted price on the same item is replaced (last pick wins). Nothing is emailed.

Optional `items` (`type`, `catalog_item_id`, `line_item_id`) picks lines from a multi-item quote; without it every line the vendor priced is accepted. Returns `data` (the quote), `accepted` (with `price_id` and `estimate_lines_updated`), `skipped` (with `reason`), `rfq_status`.

### Use a price on the estimate only

`POST /v1/rfq-responses/{response_id}/use-price`

Puts the quoted price on the estimate's matching lines without accepting the quote: no saved vendor price, and the request stays open. Optional `items` as above. All-or-nothing: a quote whose unit doesn't match the estimate line is refused with the reason.

### Tell the vendor they won

`POST /v1/rfq-responses/{response_id}/notify-accepted`

**Emails the vendor** that their quote was accepted, only after it was. Uses your saved acceptance email unless you send `subject` and/or `message` (plain text). A multi-item request names the item (`type`, `catalog_item_id`, `line_item_id`). Returns `delivered`, `email`, `item_name`, `price`, `unit`.

## Line scope for subcontractors

The scope a subcontractor reads for a line comes from the line's `internal_scope`, set with `PATCH /v1/estimates/{id}/line-items/{line_item_id}` ([Estimates](estimates-and-proposals.md)).
