# Proposals

A proposal is the customer-facing document an [estimate](estimates-and-proposals.md) becomes: cover, scope, pricing, terms and an acceptance page, at a private link the customer opens in a browser (and as a PDF). TruTec tracks when it's sent, viewed, accepted or declined, and who signed.

**Lifecycle.** `draft` → `sent` → `viewed` → `accepted` or `rejected`. `void` is a proposal the office withdrew (a revised one replaces it).

- `POST /v1/estimates/{id}/proposal` creates the proposal from the estimate, or refreshes it with the estimate's current prices. Nothing is sent.
- `PATCH /v1/proposals/{id}` edits the title, addressee, valid-until date, wording and sections (About us, scope, terms, custom sections, pages, deposit, photos).
- `POST /v1/proposals/{id}/share-link` gets the customer link without sending anything; `PATCH /v1/proposals/{id}/link` turns it off or on.
- `POST /v1/proposals/{id}/send` **emails the customer**, exactly as the Send button in TruTec does.
- `GET /v1/proposals/{id}` and `GET /v1/proposals/{id}/signature` track it; webhooks fire on `proposal_sent`, `proposal_viewed`, `proposal_accepted` and `proposal_declined` ([Webhooks & events](webhooks-and-events.md)).

When the customer accepts, TruTec records who signed and moves the job to the stage your workspace maps acceptance to, the same as in the app. Writes act as the user who created the API key, with that user's permissions ([Authentication](authentication.md)).

!!! warning
    `POST /v1/proposals/{id}/send` is the only call on this page that reaches a customer. It emails them from your workspace and can start your workspace's automatic follow-up emails. Confirm the recipient before you call it, especially from an AI assistant.

Timestamps are UTC ISO 8601 with a `Z`. Money is in dollars.

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

## The proposal object

| Field | Type | Description |
|---|---|---|
| `id` | integer | TruTec id. |
| `estimate_id` | integer or null | The estimate it was made from. |
| `job_id` | integer | |
| `status` | string | `draft`, `sent`, `viewed`, `accepted`, `rejected` or `void`. |
| `title` | string | The proposal's title. |
| `client_name`, `client_email`, `client_company` | string or null | Who it's addressed to. Send uses `client_email` unless you give another address. |
| `valid_until` | string or null | `YYYY-MM-DD`. |
| `share_url` | string or null | The customer's link. Null when the link is turned off or expired. |
| `is_customer_copy` | boolean | A copy of the job's proposal for an additional customer. |
| `approval_status` | string | `not_required`, `pending` (waiting on an owner before it can be sent), `approved` or `rejected`. |
| `sent_at`, `first_viewed_at`, `last_viewed_at`, `accepted_at`, `rejected_at` | string or null | |
| `view_count` | integer | Times the customer opened it. |
| `created_at`, `updated_at` | string | |
| `totals` | object | `subtotal`, `tax`, `total`, `optional_total`, `deposit`. The numbers the customer sees (frozen once sent). Single-proposal responses only. |
| `content` | object | The wording, as HTML: `cover_html`, `intro_html`, `scope_html`, `terms_html`, `closing_html`. Single-proposal responses only. |
| `signature` | object | The signature status object below. Single-proposal responses only. |
| `pdf_url` | string | `GET` it with your key for the PDF. Single-proposal responses only. |
| `link` | object | The customer link: `has_link`, `enabled` (not turned off), `expires_on` (or null) and `active` (the customer can open it today). Single-proposal responses only. |
| `sections` | object | The editable sections as the customer sees them (see [Edit the sections](#edit-the-sections)). Single-proposal responses only. |

## List proposals

`GET /v1/proposals`

**Permission:** Read jobs

Most recently changed first. With `updated_after`, sorted by `updated_at`, oldest first, so you can page forward.

| Name | Type | Required | Description |
|---|---|---|---|
| `estimate_id` | integer | No | Only this estimate's proposals. |
| `job_id` | integer | No | Only this job's. |
| `status` | string | No | One of the statuses above. |
| `updated_after` | string | No | ISO 8601 timestamp. |
| `limit` | integer | No | Default 50, maximum 200. |
| `offset` | integer | No | Default 0. |

```bash
curl "https://trutec.ai/api/v1/proposals?status=viewed" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 3312,
      "estimate_id": 9840,
      "job_id": 70412,
      "status": "viewed",
      "title": "Northgate Plaza Sealcoat",
      "client_name": "Pat Sample",
      "client_email": "pat@northgate.example",
      "client_company": "Northgate Property Group",
      "valid_until": "2026-11-05",
      "share_url": "https://trutec.ai/portal/v3/z2eY7SFuWzWC2L02BeDVhc5b33Ud4kja",
      "is_customer_copy": false,
      "approval_status": "not_required",
      "sent_at": "2026-10-06T19:31:10Z",
      "first_viewed_at": "2026-10-06T21:02:44Z",
      "last_viewed_at": "2026-10-08T15:17:03Z",
      "view_count": 3,
      "accepted_at": null,
      "rejected_at": null,
      "created_at": "2026-10-02T15:12:09Z",
      "updated_at": "2026-10-08T15:17:03Z"
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

**Errors:** `400` `status must be one of …`. `403` `API key lacks job read permission`.

## Get a proposal

`GET /v1/proposals/{id}`

**Permission:** Read jobs

```bash
curl "https://trutec.ai/api/v1/proposals/3312" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": {
    "id": 3312,
    "estimate_id": 9840,
    "job_id": 70412,
    "status": "accepted",
    "title": "Northgate Plaza Sealcoat",
    "client_name": "Pat Sample",
    "client_email": "pat@northgate.example",
    "client_company": "Northgate Property Group",
    "valid_until": "2026-11-05",
    "share_url": "https://trutec.ai/portal/v3/z2eY7SFuWzWC2L02BeDVhc5b33Ud4kja",
    "approval_status": "not_required",
    "sent_at": "2026-10-06T19:31:10Z",
    "first_viewed_at": "2026-10-06T21:02:44Z",
    "last_viewed_at": "2026-10-09T13:10:51Z",
    "view_count": 4,
    "accepted_at": "2026-10-09T13:12:47Z",
    "rejected_at": null,
    "totals": { "subtotal": 48500.0, "tax": 0.0, "total": 48500.0, "optional_total": 1850.0, "deposit": null },
    "pdf_url": "/api/v1/proposals/3312/pdf",
    "content": {
      "cover_html": "<p>Thank you for the opportunity to review this project.</p>",
      "intro_html": "<p>We pair experienced crews with modern equipment…</p>",
      "scope_html": "<p>The scope below is based on measured site conditions…</p>",
      "terms_html": "<ul><li>Pricing is based on the scope, quantities, and site conditions described in this proposal.</li></ul>",
      "closing_html": "<p>To move forward, please review and approve the authorization section.</p>"
    },
    "signature": {
      "proposal_id": 3312,
      "proposal_status": "accepted",
      "accepted_at": "2026-10-09T13:12:47Z",
      "rejected_at": null,
      "signer_name": "Pat Sample",
      "signer_email": "pat@northgate.example",
      "signed_at": "2026-10-09T13:12:47Z",
      "envelope": null
    },
    "is_customer_copy": false,
    "created_at": "2026-10-02T15:12:09Z",
    "updated_at": "2026-10-09T13:12:47Z"
  }
}
```

**Errors:** `404` `Proposal not found`.

## Create or refresh an estimate's proposal

`POST /v1/estimates/{id}/proposal`

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

The sheet's **Proposal** button. The first call creates the proposal from the estimate, filled with your workspace's default wording, terms, clauses and template, addressed to the job's contact, valid for 30 days, with a customer link ready. Later calls refresh its prices and lines from the estimate (a sent or accepted proposal keeps the version the customer saw). For a change-order estimate it makes the change-order document. Nothing is sent. No body.

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

Returns `201` with the proposal (the single-proposal shape).

**Errors:** `404` `Estimate not found`. `409` with the reason when the estimate can't go to a proposal yet.

## Update a proposal

`PATCH /v1/proposals/{id}`

**Permission:** Write jobs (the key's user needs pricing access)

Only the fields you send change. HTML is cleaned the same way the proposal builder cleans it. A proposal that is out for signature can't be edited (`409`).

| Name | Type | Description |
|---|---|---|
| `title` | string | |
| `client_name`, `client_email`, `client_company` | string | Who it's addressed to. |
| `valid_until` | string or null | `YYYY-MM-DD`. |
| `cover_html`, `intro_html`, `scope_html`, `terms_html`, `closing_html` | string or null | Wording, as HTML. |
| `accent_color` | string | Hex color, for example `#2563eb`. |

```bash
curl -X PATCH "https://trutec.ai/api/v1/proposals/3312" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"title": "Northgate Plaza — Sealcoat & Restripe", "valid_until": "2026-12-31"}'
```

Returns `200` with the proposal.

**Errors:** `400` `valid_until must be a date (YYYY-MM-DD) or null`, `Unknown fields: …`. `404` `Proposal not found`. `409` while out for signature.

### Edit the sections

The same `PATCH` changes the proposal's sections, exactly as the proposal builder saves them. Nothing is sent, but a proposal that's already sent shows the new wording at its link. Read the current values in `sections` on `GET /v1/proposals/{id}`: each shows the proposal's own wording, or the company default when the proposal has none.

| Name | Type | Description |
|---|---|---|
| `about_us` | object | `title`, `body_html`. An empty string returns that part to the company default. |
| `scope` | object | The scope of work section: `title`, `intro`, `notes_html`. |
| `terms` | object | `title`, `body_html`, `footer`, and `items`: the option rows the customer sees, `[{text, on}]`, which **replace the whole list**. `reset: true` drops this proposal's own terms and follows the company's again. |
| `custom_sections` | array | Your own sections (up to 32), `[{id, title, body, items: [{text, on}], enabled}]`. **Replaces the whole list**: keep a section's `id` to update it, leave a section out to remove it. |
| `section_order` | array | Section ids in order: `overview`, `about`, `scope`, `service_info`, `pricing`, `photos`, `authorization`, `terms`, `acceptance` and `custom:<id>`. |
| `pages` | object | Which pages print, page id to `true` or `false` (`cover`, `video`, `overview`, `about`, `scope`, `service_info`, `pricing`, `photos`, `authorization` and more). Only the pages you name change. |
| `deposit_percent` | number or null | `25` = a 25% deposit. Shown on the proposal and used by deposit invoices. |
| `payment_due_days` | integer or null | Days until an invoice is due (the proposal's payment terms). |
| `photo_ids` | array | Job photos to show on the proposal (photos on the proposal's job). `[]` = every photo marked for reports. |
| `photo_layout` | string | `detail` or `grid`. |
| `show_photos` | boolean | Show the photos section. |
| `display` | object | On/off switches: `show_firm_logo`, `show_client_logo`, `show_preparer_card`, `show_scope_pin_details`, `show_quantity_column`, `show_unit_price_column`, `show_line_numbers`, `show_item_numbers`, `tax_in_item_prices`, `lump_sum_pricing`, `group_lines_separately`, `show_photo_titles`, `show_photo_captions`, `show_photo_dates`. |

`about_us.body_html`, `scope.notes_html` and `terms.body_html` are the same text as `intro_html`, `scope_html` and `terms_html`; send one or the other, not both. A line's own wording on the proposal is changed on the estimate line ([Estimates](estimates-and-proposals.md)).

```bash
curl -X PATCH "https://trutec.ai/api/v1/proposals/3312" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "deposit_percent": 25,
    "terms": {"items": [
      {"text": "Work is weather permitting; rain days move the start date.", "on": true},
      {"text": "Owner to remove vehicles before 7am.", "on": true}
    ]},
    "pages": {"photos": false}
  }'
```

**Errors:** `400` for a part the section doesn't take (`terms does not take: …`), an unknown page or display switch, or a deposit outside 0–100. `404` `Photo not found on this job`.

## The customer link

The proposal's private link is `share_url`. These two calls get it and control it without emailing anyone; to email it, use [Send a proposal](#send-a-proposal).

### Get the customer link

`POST /v1/proposals/{id}/share-link`

**Permission:** Write jobs (the key's user needs pricing access)

The builder's **Copy link**: creates the link when the proposal has none and returns it in `url` (with the proposal in `data`). The proposal is not marked sent. A voided proposal goes back to draft. `regenerate: true` replaces the link; **the old link stops working**.

```bash
curl -X POST "https://trutec.ai/api/v1/proposals/3312/share-link" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "url": "https://trutec.ai/portal/v3/z2eY7SFuWzWC2L02BeDVhc5b33Ud4kja",
  "data": {"id": 3312, "status": "draft", "share_url": "https://trutec.ai/portal/v3/z2eY7SFuWzWC2L02BeDVhc5b33Ud4kja", "...": "..."}
}
```

**Errors:** `409` `approval_required` when the proposal is over the user's send-approval limit (the owners are asked to approve it; ask again once approved), `409` while it's out for signature.

### Turn the link off or on

`PATCH /v1/proposals/{id}/link`

**Permission:** Write jobs

| Name | Type | Description |
|---|---|---|
| `enabled` | boolean | `false` turns the link off (the customer sees it's no longer available); `true` turns the same link back on. |
| `expires_on` | string or null | The day the link stops working (`YYYY-MM-DD`), or `null` for never. |

Returns the proposal; its `link` object shows the result. Moving `valid_until` later also moves an earlier link expiry with it.

## Apply a proposal template

`POST /v1/proposals/{id}/apply-template`

**Permission:** Write jobs

Applies one of the workspace's saved proposal templates (layout and pages) to a draft proposal.

| Name | Type | Required | Description |
|---|---|---|---|
| `template_id` | string | Yes | From `GET /v1/proposal-templates`. |

Returns `200` with the proposal. **Errors:** `404` `That proposal template is no longer available.` `409` when the proposal isn't a draft.

## Send a proposal

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

**Permission:** Write jobs (the key's user needs pricing access)

**Emails the customer.** The same as **Send** in the proposal builder: it re-checks the price, emails the customer a link to the live proposal (from the sending user's connected mailbox when the workspace requires it), freezes the version the customer sees, marks the proposal `sent`, logs the email on the job, and starts the workspace's follow-up sequence if one is set up. Sending again re-sends the link.

| Name | Type | Required | Description |
|---|---|---|---|
| `to_email` | string | No | Defaults to the proposal's `client_email`. |
| `cc` | array of strings | No | Up to 10 addresses. |
| `subject` | string | No | Defaults to your workspace's proposal email subject. Merge fields such as `{{job_name}}` work. |
| `message` | string | No | Defaults to your workspace's proposal email. `{{link}}` places the proposal link. |

```bash
curl -X POST "https://trutec.ai/api/v1/proposals/3312/send" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"cc": ["office@northgate.example"]}'
```

```json
{
  "data": {
    "id": 3312,
    "status": "sent",
    "sent_at": "2026-10-12T16:40:02Z",
    "share_url": "https://trutec.ai/portal/v3/z2eY7SFuWzWC2L02BeDVhc5b33Ud4kja"
  },
  "sent_to": "pat@northgate.example"
}
```

(Response trimmed: `data` is the full proposal.)

When the total is over the sending user's approval limit, nothing is emailed: the workspace's owners are asked to approve it and you get

```json
{
  "error": "This proposal is over the sending user's approval limit. The workspace owners were asked to approve it; send again once approved.",
  "code": "approval_required",
  "amount": 48500.0
}
```

with status `409`. Send again once `approval_status` is `approved`.

**Errors:** `400` `A valid recipient email is required.`, `cc must be a list of email addresses`. `409` `approval_required`; `409` when the proposal is out for signature or the sending user has no connected mailbox and the workspace requires one. `502` when the email could not be delivered (nothing changes on the proposal).

## Download the PDF

`GET /v1/proposals/{id}/pdf`

**Permission:** Read jobs

Returns `application/pdf`, the same document as **Download PDF** in TruTec.

```bash
curl -o proposal.pdf "https://trutec.ai/api/v1/proposals/3312/pdf" \
  -H "X-API-Key: sk_your_api_key_here"
```

**Errors:** `404` `Proposal not found`. `422` when an attached page can't be rendered. `503` when PDF rendering is briefly unavailable; retry.

## Signature status

`GET /v1/proposals/{id}/signature`

**Permission:** Read jobs

Who accepted and when, and the latest e-signature request.

```json
{
  "data": {
    "proposal_id": 3312,
    "proposal_status": "sent",
    "accepted_at": null,
    "rejected_at": null,
    "signer_name": null,
    "signer_email": null,
    "signed_at": null,
    "envelope": {
      "id": 811,
      "provider": "trusign",
      "status": "delivered",
      "status_detail": null,
      "recipient_name": "Pat Sample",
      "recipient_email": "pat@northgate.example",
      "sent_at": "2026-10-12T16:41:00Z",
      "delivered_at": "2026-10-12T16:41:02Z",
      "completed_at": null,
      "declined_at": null,
      "voided_at": null,
      "signed_document_available": false
    }
  }
}
```

`envelope.status` is `created`, `scheduled`, `sent`, `delivered`, `completed`, `declined` or `voided`. `envelope` is null when no signature request was sent (customers can still accept in the proposal itself; `signer_name` and `signed_at` show that). Signature requests are sent from TruTec.

## Proposal templates and clauses

### List proposal templates

`GET /v1/proposal-templates`

**Permission:** Read jobs

The workspace's saved proposal templates.

```json
{
  "data": [
    { "id": "kq3X9sT1bQe", "name": "Commercial", "description": "Cover, scope, pricing, terms" }
  ],
  "pagination": { "total": 1, "limit": 1, "offset": 0 }
}
```

### List proposal clauses

`GET /v1/proposal-clauses`

**Permission:** Read jobs

The reusable terms clauses. Clauses with `is_default` are added to every new proposal; the rest are picked per proposal in TruTec.

```json
{
  "data": [
    { "id": "cl-4e1a9b0c2d3f", "title": "Weather", "text": "Work is weather permitting and may be rescheduled.", "bold": false, "is_default": true }
  ],
  "pagination": { "total": 1, "limit": 1, "offset": 0 }
}
```
