Create a draft quote
- Permissions
quotes:create,quotes:read,customers:read,products:read+ conditional- Safe retries
Idempotency-Key- Also available as
fieldtask_create_quote
Creates one draft quote for an existing customer, priced from your product and service catalog.
A draft quote is only saved in FieldTask. Creating it doesn't send anything to the customer, doesn't ask them to accept it, and doesn't create a job or an invoice. The quote stays a draft until someone in your organization sends it from FieldTask.
The quote and all of its line items are saved together or not at all. This endpoint only creates: it never matches or updates an existing quote. If you send the same body twice without the same Idempotency-Key, you get two quotes. See Idempotency and safe retries.
This page is generated from contract 2026-11-03-review.2, a review export that hasn't been released.
Before you start
You need:
- API access for your organization. An owner or admin requests it in Settings → API connections. Until FieldTask enables it, every request returns
403 api_access_not_enabled. - Quotes available for your organization. FieldTask makes Quotes available organization by organization, and your organization must not have switched Quotes off. Your organization's currency must also use no more than two decimal places. If any of these isn't true, every quote request returns
404 resource_not_found. - A REST credential with the permissions below: an API token created in Settings → API connections, or an access token from an OAuth client-credentials connection. Tokens from an MCP sign-in don't work with the REST API.
- The ID of an existing customer. Find it with
GET /public/v1/customers, or create the customer first withPOST /public/v1/customers. Archived customers can be used. - The product and price IDs for every line item. Find them with
GET /public/v1/products. Each line item is priced from the catalog; you can't send your own prices. - Optionally, a location ID from
GET /public/v1/customers/{customer_id}/locations, if the quote is for a particular site.
Permissions
Every request needs all of these permissions:
| Permission | Dependency in the permission map |
|---|---|
quotes:create | Depends on customers:read, products:read, quotes:read |
quotes:read | Depends on customers:read |
customers:read | No dependencies |
products:read | No dependencies |
Some inputs need more:
| When | Additional permission | Including its dependencies |
|---|---|---|
location_id is not null | customer_locations:read | customer_locations:read, customers:read |
Without customer_locations:read, a request that includes location_id is rejected with 403 insufficient_scope.
REST connections can't apply a discount to a quote. See Discounts.
Adding a permission to a connection doesn't happen automatically. An owner or admin edits it in Settings → API connections. API tokens keep the same token string when their permissions change. OAuth integrations must also request the new scope when they get an access token.
Request
Responses
- 201
- 400
- 401
- 403
- 404
- 405
- 409
- 410
- 413
- 415
- 422
- 429
- 503
Created
Response Headers
Responses must not be cached.
The operation key used for this create, including a server-generated key when omitted.
Server-generated request correlation ID.
Bad Request
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Unauthorized
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Forbidden
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Not Found
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Method Not Allowed
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Conflict
Response Headers
Responses must not be cached.
Seconds before retry; present on 429 and an in-progress operation.
Server-generated request correlation ID.
Gone
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Request Entity Too Large
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Unsupported Media Type
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Unprocessable Entity
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Too Many Requests
Response Headers
Responses must not be cached.
Seconds before retry; present on 429 and an in-progress operation.
Server-generated request correlation ID.
Service Unavailable
Response Headers
Responses must not be cached.
Server-generated request correlation ID.
Field guide
The request body is a single JSON object. Only customer_id and items are required.
| Field | Required | What to send |
|---|---|---|
customer_id | Yes | ID of a customer in this organization. Archived customers are accepted. |
items | Yes | 1–100 catalog line items. See Line items. |
title | No | Up to 512 characters. Defaults to an empty string. |
location_id | No | ID of an active location that belongs to customer_id. Needs customer_locations:read. Without it, the quote has no location. |
issue_date | No | A calendar date such as 2026-11-03. Defaults to the current date in UTC. See Dates and time zones. |
expiry_at | No | The time the quote stops being valid, as a timestamp with a UTC offset. Defaults to the creation time plus your organization's default quote validity period. |
tax_rate | No | A percentage as a decimal string, such as "8.25" for 8.25%. From 0 to 999.99, with up to 2 decimal places. Defaults to your organization's default quote tax rate. |
discount | No | {"type": "percent" or "fixed", "amount": "…"}. REST connections can only send an amount of zero. See Discounts. |
terms | No | Up to 8,192 characters. Defaults to your organization's default quote terms. |
customer_note | No | A note for the customer, up to 4,096 characters. Defaults to null. |
Anything else in the body is rejected with 422 validation_failed, including fields you might expect to set such as status, quote_number, currency or a custom unit price. The organization and the currency always come from your credential's organization.
Decimal values are JSON strings, never numbers. Send "8.25", not 8.25.
Body and header rules
- Send
Content-Type: application/jsonwith an uncompressed body. AContent-Encodingheader is rejected with415. - The body can be at most 256 KiB and nest at most 10 levels deep.
- Duplicate property names,
NaNandInfinityare rejected with400 invalid_json. - The URL must have no query string and no trailing slash.
Line items
Each entry in items adds one product or service from your catalog to the quote.
| Item field | Required | What to send |
|---|---|---|
product_id | Yes | ID of a product or service from GET /public/v1/products. |
price_id | Sometimes | ID of one of the product's named prices. Required when more than one price option applies. |
price_source | No | price or legacy. Send legacy to use the product's legacy unit price explicitly. |
quantity | No | A positive decimal string with up to 8 whole digits and 2 decimal places, such as "1" or "2.50". Zero isn't allowed. Defaults to "1.00". |
- The product must be active and available in your catalog. A product marked unavailable is rejected.
- Only prices in your organization's currency are considered. If no price option matches your selection, or more than one does, the request is rejected with
422 validation_failedrather than FieldTask choosing one for you. Sendprice_idwhenever a product has more than one price. - The item's name, description and unit price are copied from the catalog when the quote is created. Later catalog changes don't change the quote.
- Every item created through the API is a catalog item. You can't add custom-priced lines or text-only lines through the API.
- If any item is invalid, no quote is created.
Totals, taxes and discounts
FieldTask calculates the quote's totals; you can't send them. The calculation is:
- Each line amount is quantity × unit price.
subtotalis the sum of the line amounts.discount_amountis a percentage of the subtotal, or a fixed amount. It's never less than zero or more than the subtotal.tax_amountistax_ratepercent of the subtotal minus the discount.totalis the subtotal minus the discount plus the tax.
Each step is rounded to the currency's smallest unit, with halves rounded up: cents for currencies such as USD, whole units for currencies without decimals. For example, 8.25% of 370.00 is 30.525, which becomes "30.53".
| Response field | Meaning |
|---|---|
subtotal | Sum of line amounts, before discount and tax. |
discount_type | percent, fixed, or null when no discount was sent. |
discount_value | The discount you sent: a percentage or an amount. "0.00" when none. |
discount_amount | The discount in money, as applied. |
tax_rate | The tax percentage used. |
tax_amount | The tax in money. |
total | The final quote total. |
All amounts are decimal strings with two decimal places in the quote's currency. Parse them as decimals, not floating-point numbers, and display the returned values rather than recalculating them. Invoices round differently; see Create a draft invoice.
Discounts
Whether a request can apply a discount depends on who it acts for:
- REST connections (API tokens and OAuth client-credentials connections) act as the connection, not as a person, so they can't apply a discount. A
discountwith an amount other than zero is rejected with403 operation_forbidden, and nothing is created. Leavediscountout, and add the discount in FieldTask before the quote is sent. - MCP connections act for the owner or admin who approved them, so
fieldtask_create_quotecan apply a discount. See fieldtask_create_quote.
Dates and time zones
issue_dateis a calendar date inYYYY-MM-DDformat, with no time or time zone. When you leave it out, FieldTask uses the current date in UTC, which can differ from your local date late in the day. Send it explicitly if the date matters.expiry_atis an instant. Send a full timestamp with seconds and an explicit UTC offset orZ, such as2026-12-03T23:59:59-05:00. A timestamp without an offset, or with the offset-00:00, is rejected with422 validation_failed. FieldTask stores and returns it in UTC with aZsuffix, so that value comes back as2026-12-04T04:59:59Z. Use the offset in effect for your customer on that date.
See Dates and times for the conventions every endpoint follows.
The draft you get back
A successful create returns the quote with every field, including its items, terms and note:
statusisdraft.quote_numberis assigned by FieldTask from your organization's quote numbering. You can't choose it.selected_revisiondescribes the version of the quote whose values are shown: for a new quote, revision1withstatedraftand nosent_atoraccepted_at.customerandlocationsummarize the customer and location.locationis included only when the connection hascustomer_locations:read, and isnullwhen the quote has no location.
Read the quote again later with GET /public/v1/quotes/{id}, using the ID from data.id or the Location header.
Idempotency and safe retries
Networks fail after a request is sent. The Idempotency-Key header lets you retry without creating a duplicate quote.
- Before sending, generate a new key for the quote, such as a UUID, and save it with your own record.
- Send the request with that key.
- If you get no response, a timeout or a
5xx, send the same body with the same key again, with backoff.
| Situation | Result |
|---|---|
| New key | The quote is created. Response 201, Idempotency-Replayed: false. |
| Same key, same body, within 7 days | The original result is returned again. No second quote is created. Response 201, Idempotency-Replayed: true. |
| Same key, different body | 409 idempotency_key_reused. Nothing is created. |
| Same key while the first request is still running | 409 operation_in_progress with Retry-After. Retry with the same key and body. |
| Same key more than 7 days later | 409 idempotency_window_expired. A key can never be reused for a different quote. |
A replay returns the original response, not the quote's current state. If the customer or location in that response is no longer visible to the connection, the replay is rejected with 403 operation_forbidden; if the quote has been deleted, with 410 resource_gone.
Quotes have no external_reference, so keep the returned data.id with your own record. See Idempotency and recovery for key scope, what counts as the same body, and how replays are authorized.
Local creation and external synchronization
A 201 response means the draft quote is saved in FieldTask. Nothing further is needed for it to exist, and follow-up work never removes it.
If your organization synchronizes quotes with a connected CRM, FieldTask also sends the new draft there after it's saved. That follow-up only creates the record in the connected CRM. It doesn't send the quote to the customer. Its progress is reported on the operation, not on the quote.
To check it, read the Operation-Location URL from the 201 response with the same credential. It needs quotes:read and customers:read, which the create already requires:
curl "$FIELDTASK_API_ORIGIN/public/v1/operations/OPERATION_ID" \
--header "Authorization: Bearer $FIELDTASK_API_TOKEN"
data.local_status is always committed. data.integration.status is not_applicable when your organization doesn't synchronize quotes. For the other values, see Local creation and external synchronization.
The initial synchronization attempt is reported as blocked with reconciliation_required when:
- the quote has no location: send
location_idif you want the quote synchronized; - the quote was changed in FieldTask, or is no longer a draft, before synchronization ran;
- the customer or location was removed in the meantime.
It's reported as outcome_unknown when FieldTask couldn't confirm whether the connected CRM received the quote. Check there before doing anything that could create a duplicate.
For quotes, integration.status describes FieldTask's initial attempt to synchronize the new quote, not everything that happens to it afterwards. FieldTask's own quote synchronization can still send the quote to the connected CRM after that attempt was reported as blocked. A blocked status therefore doesn't mean the quote never reached the connected CRM.
Before you create or update the quote in the connected CRM yourself, check whether it's already there. The quote is always created in FieldTask, whatever the status.
Operation results are kept for seven days. After that, GET /public/v1/operations/{id} returns 410 operation_result_expired. That doesn't mean the quote wasn't created.
Recovering from failures
| What happened | What to do |
|---|---|
Timeout, connection error or 5xx | Retry the same body with the same key, with exponential backoff. |
409 operation_in_progress | Wait for Retry-After seconds and retry with the same key. |
429 | Wait for Retry-After seconds and retry with the same key. |
201 received | Store data.id, data.quote_number and the Location and Operation-Location headers. Stop retrying. |
| Key older than 7 days, or the result expired | Don't create again yet. Look for the quote with GET /public/v1/quotes?customer_id=…&status=draft or your saved quote ID. |
Never switch to a new key just because a response was lost. A new key means a new quote.
Errors
Errors use application/problem+json. The top-level code is the stable machine-readable value; error adds a readable message, a suggested fix and a documentation link:
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"code": "validation_failed",
"detail": "The request contains invalid fields.",
"instance": "urn:uuid:99999999-9999-4999-8999-999999999993",
"request_id": "99999999-9999-4999-8999-999999999993",
"error": {
"code": "validation_failed",
"message": "The request contains invalid fields.",
"fix": "Check the documented request fields.",
"docs_url": "https://api.fieldtask.example/public/docs/"
}
}
error.code usually equals code. For a missing permission, code is insufficient_scope and error.code is missing_permission. Validation errors don't say which field failed, and error responses never echo submitted values.
| Status | code | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_query | The URL has a query string. Create requests accept none. | Send the request to /public/v1/quotes with no query parameters. |
| 400 | invalid_json | The body isn't one valid JSON value, repeats a property name, uses NaN/Infinity or nests deeper than 10 levels. | Send a single JSON object encoded as UTF-8. |
| 400 | invalid_operation_key | Idempotency-Key isn't 16–128 printable ASCII characters. | Use a UUID or another 16–128 character key without spaces at either end. |
| 401 | invalid_token | The bearer token is missing, malformed, expired, revoked or issued for a different resource (for example an MCP sign-in). | Send a current REST API token or REST OAuth access token as Authorization: Bearer …. |
| 403 | api_access_not_enabled | FieldTask hasn't enabled API access for this organization, or has disabled it. | An owner or admin requests access in Settings → API connections. |
| 403 | insufficient_scope | The connection lacks a permission this request needs, including customer_locations:read when the body has location_id. error.code is missing_permission. | Add the permission named in error.message to the connection, then retry with the same key. |
| 403 | operation_forbidden | The body has a discount with a nonzero amount, which REST connections can't apply; the connection's access changed while the request was processed; or a replay would return a customer or location the connection can no longer read. | Leave out discount and add it in FieldTask before sending the quote. Otherwise check the connection's permissions and retry with the same key and body. |
| 404 | resource_not_found | Quotes aren't available for your organization or in this environment, or the URL is wrong. | Check that Quotes is available and switched on for your organization, and that the path has no trailing slash. |
| 405 | method_not_allowed | Quote creation isn't enabled in this environment. The Allow header lists the methods that are. | Contact FieldTask if you expected quote creation to be available. |
| 409 | idempotency_key_reused | This key was already used with a different body. | Resend the original body with this key, or use a new key for a different record. |
| 409 | operation_in_progress | A request with the same key is still being processed. | Wait for Retry-After seconds, then retry with the same key and body. |
| 409 | idempotency_window_expired | This key was used more than seven days ago. It can't be replayed or reused. This doesn't mean the original request failed. | Reconcile using your saved quote ID, or list the customer's draft quotes, before creating anything new. |
| 410 | resource_gone | A replay of this key found that the quote it created has since been deleted. | Don't create it again automatically. Check in FieldTask whether it was removed on purpose. |
| 413 | payload_too_large | The body is larger than 256 KiB. | Reduce the body, for example by shortening terms. |
| 415 | unsupported_media_type | Content-Type isn't application/json, or the body is compressed. | Send an uncompressed body with Content-Type: application/json. |
| 422 | validation_failed | A field is missing, unknown or invalid; the customer, location or a product isn't eligible; a price selection matches no option or more than one; or a quantity is zero. | Check the request against the field guide, and send price_id for products with several prices. The response doesn't name the field. |
| 429 | rate_limit_exceeded | Too many requests in the current minute, or too many concurrent requests, for the organization or connection. | Wait for Retry-After seconds. Retry creates with the same key. |
| 429 | daily_quota_exceeded | The organization or connection has used its daily create allowance. Allowances reset at 00:00 UTC. | Wait for Retry-After seconds, or check remaining allowance in Settings → API connections. |
| 503 | temporarily_unavailable | The service couldn't complete the request. The quote may or may not have been created. | Retry with the same key and body, with exponential backoff. |
Every response, including errors, has an X-Request-ID header. Include it, with the time and the status code, when you contact support. Never send tokens or secrets.
Limits
Quote creates count against the same limits as other creates: per-minute and concurrency limits, and a daily create allowance that resets at 00:00 UTC, for both the organization and each connection. When you hit a limit you get 429 with a Retry-After header in seconds. Replaying an already-created quote with its original key doesn't count as another create. See Rate limits.
Response headers
A successful create returns 201 Created with these headers:
| Header | Always present | Value |
|---|---|---|
Cache-Control | Yes | Always no-store |
Idempotency-Key | Yes | string |
Idempotency-Replayed | Yes | string |
Location | Yes | string |
Operation-Location | Yes | string |
X-Request-ID | Yes | uuid |
The server also returns the Idempotency-Key that was used, including one it generated.