# POST /v1/quotes/{quoteId}/attachments

**Service:** Quotes  
**Operation:** `QuoteService_UploadAttachment`

Uploads one file, up to 20 MB, into the quote's conversation, where it appears immediately in the Factory app's Collaborate tab. Send the file content (base64-encoded over REST) with its filename and, optionally, a plain-text caption — with a caption the upload reads as a message carrying a file; without one it is a bare attachment, which is how most files are posted. The created conversation message is returned, including the attachment's id and a presigned download URL. HEIC and HEIF images are converted to JPEG on upload. To make a retry safe, send an idempotency key in `requestId`; a repeat with the same key can never attach the file twice — it is rejected with HTTP 409 carrying the original message's id.

## Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `quoteId` | path | string | yes | The id of the quote to attach to. Required. |

## Request body

`QuoteServiceUploadAttachmentBody` (application/json)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `file` | string | yes | The file content, up to 20 MB. Required. |
| `filename` | string | yes | The file's name, including its extension. Required. |
| `contentType` | string | no | The file's media type (for example `image/png`). Optional; derived from the filename when omitted. |
| `caption` | string | no | Optional plain-text caption. The upload always creates one conversation message carrying the file; the caption becomes that message's text, stored in the same HTML-escaped, `<p>`-wrapped form as a posted message. |
| `requestId` | string | no | Optional idempotency key for this request: an opaque, client-generated UUID, scoped to the API key that sends it. Requests carrying the same key are executed at most once, so a retry can never create a second copy. Any repeat is rejected with HTTP 409: if the original request completed, the body's `resourceId` carries the id it created; if its outcome is still unknown, or the key is reused with a materially different body, verify with a read before retrying with a fresh key. Keys are retained for at least 24 hours. If the idempotency store is unavailable, requests carrying a key are rejected with HTTP 503 (requests without a key are unaffected). Omit the key for no idempotency guarantee. |
| `fields` | string | no | Comma-separated response fields to include, using camelCase JSON names (e.g. `orderId,total.amountMicros`). Dot paths reach into nested objects and map transparently across arrays. Paths are relative to the resource, not the response envelope; envelope keys are always preserved. Only 2xx JSON responses are filtered; error bodies pass through unmodified. Unknown names are silently ignored. Takes precedence over `excludeFields` when both are provided. |
| `excludeFields` | string | no | Comma-separated response fields to exclude, using camelCase JSON names. Dot paths and array-transparency work the same as `fields`. Paths are relative to the resource, not the response envelope. Only 2xx JSON responses are filtered; error bodies pass through unmodified. Ignored when `fields` is also provided. |

## Responses

| Status | Schema | Description |
| --- | --- | --- |
| 200 | `quotesUploadAttachmentResponse` | A successful response. |
| 400 | `Error` | The request was rejected because it failed validation — the body is a validation-failure envelope: an overall type and message, the echoed `requestId`, and one entry per field-level problem (each with a stable code, a message, and a param pointing at the offending field). This includes a file over the 20 MB limit and a HEIC image that could not be converted. |
| 401 | `Error` | The request is missing a valid bearer token, or the token is invalid or expired. |
| 403 | `Error` | The token is valid but does not permit this action, or the resource belongs to a company the token cannot act for. The error type is permission_denied. |
| 404 | `Error` | No quote with the given id exists for the authenticated company, or the quote has been accepted and converted to an order — its conversation carries over: attach via the order-side endpoint, swapping the id's quote_ prefix for order_ (the 26-character suffix stays the same). |
| 409 | `Error` | The idempotency key in `requestId` conflicts with an earlier use: the original request already completed (`resourceId` in the error body carries the id of the message it created), its outcome is still unknown, or the key was reused with a materially different body. Verify the conversation with ListMessages or ListAttachments, then send a new `requestId` to attach a new file. |
| 429 | `Error` | The request was throttled (rate_limited) or exceeded a size limit (resource_exhausted). When throttled, the Retry-After header says how many seconds to wait before retrying; a resource_exhausted request will fail the same way if retried unchanged. |
| default | `Error` | Any other error. The body is the same error envelope every error uses: a short stable type identifying the kind of failure (for example "rate_limited" or "internal"), a human-readable message, and the request's idempotency key echoed back when one was supplied. |

## Returns

`quotesUploadAttachmentResponse`

| Field | Type | Description |
| --- | --- | --- |
| `message` | Message | The created message, carrying the uploaded file as its single attachment (with its id and a presigned download URL). |
| `message.id` | string | The id of this message. |
| `message.authorName` | string | The display name of the person who wrote the message. Empty when the author's account no longer exists. |
| `message.authorEmail` | string | The email address of the person who wrote the message. Empty when the author's account no longer exists. |
| `message.text` | string | The message text, as an HTML fragment. Messages written in the Factory app carry the app's markup (including @-mentions); plain text posted through this API is HTML-escaped and `<p>`-wrapped on write, and reads back in that stored form. Empty for file-only messages — most attachments are posted without any text. |
| `message.createdAt` | string | When the message was posted. |
| `message.attachments` | Attachment[] | The files attached to this message, if any. |
| `message.attachments.id` | string | The id of this attachment. Use it to retrieve or delete the attachment. |
| `message.attachments.messageId` | string | The id of the conversation message this attachment belongs to. |
| `message.attachments.filename` | string | The file's name, as uploaded. |
| `message.attachments.contentType` | string | The file's media type (for example `image/png` or `application/pdf`). |
| `message.attachments.sizeBytes` | string | The file's size in bytes, rounded to kilobyte precision. Files smaller than one kilobyte read as 0. |
| `message.attachments.url` | string | A presigned URL to download the file. The URL expires; re-read the attachment for a fresh one rather than storing it. |
| `message.attachments.thumbnailUrl` | string | A presigned URL to a small preview image, for image attachments. Empty when no preview exists. Expires like `url`. |
| `message.attachments.isGenerated` | boolean | True when this file is a document the Factory app generated (for example a quote or invoice PDF) rather than a file somebody uploaded. Generated documents cannot be deleted through this API. |
| `message.attachments.generatedType` | string | For generated documents, the kind of document (for example `quote` or `invoice`). Not a fixed list; new kinds may appear. Empty for uploaded files. |

---

Source: https://developer.factory.app/reference/quotes/upload-attachment · Factory Sales API v1
