Estimates
Este conteúdo não está disponível em sua língua ainda.
Estimates let you send a priced quote to a customer before work begins. Each estimate belongs to a ticket (one is created automatically if you don’t supply one), tracks its own line items and totals, and moves through a status lifecycle: draft → sent → approved / declined → converted (when turned into an invoice).
Money values in the API are objects, { "amount_cents": 1999, "amount": "19.99", "currency": "USD" }. Input fields (request body) use *_cents integers (e.g. unit_price_cents: 1999). Tax rates are percent, tax_rate: 8.25 means 8.25%.
The estimate object
Section titled “The estimate object”{ "object": "estimate", "id": "42", "public_url": "https://app.benchkey.com/estimate/42/tok_5a7e33b9", "ticket_id": "17", "converted_invoice_id": null, "status": "sent", "currency": "USD", "customer": { "name": "Jane Smith", "email": "jane.smith@example.com", "phone": "555-867-5309" }, "line_items": [ { "object": "estimate_line_item", "name": "Screen replacement", "quantity": 1, "unit_price": { "amount_cents": 12999, "amount": "129.99", "currency": "USD" }, "amount": { "amount_cents": 12999, "amount": "129.99", "currency": "USD" } }, { "object": "estimate_line_item", "name": "Labor", "quantity": 1, "unit_price": { "amount_cents": 4500, "amount": "45.00", "currency": "USD" }, "amount": { "amount_cents": 4500, "amount": "45.00", "currency": "USD" } } ], "subtotal": { "amount_cents": 17499, "amount": "174.99", "currency": "USD" }, "discount": { "amount_cents": 0, "amount": "0.00", "currency": "USD" }, "tax_rate": 8.25, "tax_amount": { "amount_cents": 1444, "amount": "14.44", "currency": "USD" }, "total": { "amount_cents": 18943, "amount": "189.43", "currency": "USD" }, "deposit": { "amount_cents": 0, "amount": "0.00", "currency": "USD" }, "approved_via": null, "approved_by": null, "valid_days": 30, "sent_via": "email", "approved_at": null, "declined_at": null, "created_at": "2025-11-15T09:10:00.000Z"}| Field | Type | Description |
|---|---|---|
id | string | Unique estimate ID |
public_url | string | null | Customer share link, the same tokenized page BenchKey emails/texts. Anyone with the URL can view and approve/decline the estimate; treat it like the emailed link. null until the estimate has a share token |
ticket_id | string|null | ID of the attached ticket (null for billing-only tickets) |
converted_invoice_id | string|null | Invoice ID if the estimate was converted, otherwise null |
status | string | draft, sent, pending, viewed, approved, declined, or converted |
currency | string | ISO-4217 currency code from tenant settings (e.g. "USD") |
customer | object | Snapshot of the customer’s name, email, and phone at estimate time |
line_items | array | Ordered list of line items |
subtotal | money | Sum of line items before discount and tax |
discount | money | Estimate-level discount amount |
tax_rate | number|null | Tax rate as a percent (e.g. 8.25 = 8.25%), or null if no tax |
tax_amount | money | Computed tax amount |
total | money | Final amount due (subtotal − discount + tax) |
deposit | money | Deposit recorded against the estimate |
approved_via | string|null | How the estimate was approved (staff, customer portal, api), or null |
approved_by | string|null | Who approved it, or null |
valid_days | integer|null | How many days the estimate is valid, or null |
sent_via | string|null | Delivery channel used when sent: "sms", "email", "both", or null |
approved_at | string|null | ISO-8601 timestamp of approval, or null |
declined_at | string|null | ISO-8601 timestamp of decline, or null |
created_at | string | ISO-8601 creation timestamp |
List estimates
Section titled “List estimates”GET /api/v1/estimatesReturns a cursor-paginated list of estimates, newest first.
Scope required: estimates.read
Query parameters
Section titled “Query parameters”| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: draft, sent, pending, viewed, approved, declined, converted |
ticket_id | string | Only estimates attached to this ticket |
customer_email | string | Only estimates for this customer email (case-insensitive) |
created_after | string | ISO-8601, only estimates created at or after this time |
created_before | string | ISO-8601, only estimates created at or before this time |
limit | integer | Page size, 1–100 (default: 25) |
cursor | string | Opaque cursor from a previous response’s next_cursor |
curl "https://app.benchkey.com/api/v1/estimates?status=sent&limit=10" \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"Node.js
Section titled “Node.js”const res = await fetch( "https://app.benchkey.com/api/v1/estimates?status=sent&limit=10", { headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" } });const { data, has_more, next_cursor } = await res.json();Response
Section titled “Response”{ "object": "list", "data": [ { "object": "estimate", "id": "42", "status": "sent" } ], "has_more": false, "next_cursor": null}Additional resource fields are omitted from this example.
See Pagination for how to page through results.
Visibility: estimates linked to hidden or soft-deleted tickets are automatically excluded from list results (and return 404 on single-GET).
Retrieve an estimate
Section titled “Retrieve an estimate”GET /api/v1/estimates/:idReturns a single estimate with all line items and computed totals.
Scope required: estimates.read
curl "https://app.benchkey.com/api/v1/estimates/42" \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"Node.js
Section titled “Node.js”const res = await fetch( "https://app.benchkey.com/api/v1/estimates/42", { headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" } });const estimate = await res.json();Response
Section titled “Response”Returns the estimate object. Returns 404 if no estimate with that ID exists, or if the estimate’s linked ticket is hidden or soft-deleted (matching the list’s visibility filter).
Create an estimate
Section titled “Create an estimate”POST /api/v1/estimatesCreates a new estimate. Supply line_items to price it on creation, or omit them to create an empty draft. Pass ticket_id to attach it to an existing ticket, or omit it to anchor the estimate to a new billing-only ticket automatically.
Scope required: estimates.write
Request body
Section titled “Request body”| Field | Required | Type | Description |
|---|---|---|---|
customer_id | yes | string | The customer this estimate is for |
ticket_id | no | string | Existing ticket to attach the estimate to |
line_items | no | array | See line item fields below |
discount_cents | no | integer | Estimate-level discount in integer cents. Requires at least one line item |
tax_rate | no | number | Tax rate as a percent (e.g. 8.25). Requires at least one line item |
Line item fields
Section titled “Line item fields”| Field | Required | Type | Description |
|---|---|---|---|
description | yes | string | Line item label (alias: name). Must be a string |
unit_price_cents | yes | integer | Unit price in integer cents (positive safe integer) |
quantity | no | number | Quantity, defaults to 1. Alias qty is also accepted. unit_price_cents × quantity must produce a whole number of cents |
cost_cents | no | integer | Unit cost in integer cents for margin reporting |
The server enforces that each line’s extended price, the aggregate line total (sum of unit_price_cents × quantity across all items), and, when tax_rate is supplied, the resulting tax amount and final total all fit within JavaScript’s safe-integer range. Exceeding any of these limits returns 400 invalid_field on the offending field (unit_price_cents for a single line, tax_rate when the tax-pushed total overflows).
curl -X POST https://app.benchkey.com/api/v1/estimates \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: est-create-ticket17-$(date +%s)" \ -d '{ "customer_id": "jane.smith@example.com", "ticket_id": "17", "line_items": [ { "description": "Screen replacement", "unit_price_cents": 12999 }, { "description": "Labor", "unit_price_cents": 4500 } ], "tax_rate": 8.25 }'Node.js
Section titled “Node.js”const res = await fetch("https://app.benchkey.com/api/v1/estimates", { method: "POST", headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa", "Content-Type": "application/json", "Idempotency-Key": `est-create-ticket17-${Date.now()}`, }, body: JSON.stringify({ customer_id: "jane.smith@example.com", ticket_id: "17", line_items: [ { description: "Screen replacement", unit_price_cents: 12999 }, { description: "Labor", unit_price_cents: 4500 }, ], tax_rate: 8.25, }),});const estimate = await res.json(); // HTTP 201Response
Section titled “Response”Returns the estimate object with HTTP 201. The total, tax, and line items reflect exactly what was supplied.
If ticket_id refers to a hidden or soft-deleted ticket, the request returns 404 not_found (ticket). Supplying an unknown customer_id or ticket_id returns 422 create_failed.
Use an Idempotency-Key header to make retries safe.
Edit an estimate
Section titled “Edit an estimate”PATCH /api/v1/estimates/:idReprices an estimate. This is a full replacement of the line items, send the complete set you want the estimate to have; discount, tax, and total are recomputed by the same internal edit flow staff use.
Editing an approved estimate materially resets it: status returns to sent, the signature block and approval provenance (approved_at, approved_via, approved_by) are cleared, and an approval_reset event is recorded, the customer must approve the new pricing. A converted estimate can no longer be edited (409 estimate_converted); it belongs to its invoice.
Scope required: estimates.write
Request body
Section titled “Request body”| Field | Required | Type | Description |
|---|---|---|---|
line_items | yes | array | Complete replacement set, same line item fields as create, at least one item |
discount_cents | no | integer | Estimate-level discount in integer cents. Must be less than the line-item subtotal |
tax_rate | no | number | Tax rate as a percent (0–100). Omit to keep the stored rate |
deposit_cents | no | integer | Deposit collected against the estimate, in integer cents. Omit to keep the stored deposit |
curl -X PATCH https://app.benchkey.com/api/v1/estimates/42 \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \ -H "Content-Type: application/json" \ -d '{ "line_items": [ { "description": "Screen replacement", "unit_price_cents": 13999 }, { "description": "Labor", "unit_price_cents": 4500 } ], "discount_cents": 1000, "tax_rate": 8.25 }'Response
Section titled “Response”Returns the updated estimate object with HTTP 200, totals, line items, and (after an approval reset) status/approved_at reflect the persisted result.
| Status | Meaning |
|---|---|
400 invalid_field | Malformed line items, discount at/above the subtotal, or out-of-range tax rate |
404 not_found | Unknown estimate, or its ticket is hidden/soft-deleted |
409 estimate_converted | The estimate was converted to an invoice |
Delete an estimate
Section titled “Delete an estimate”DELETE /api/v1/estimates/:idDeletes a draft, sent, or declined estimate (its event history and public share token are cleaned up with it, and the deletion is recorded on the ticket’s activity log).
Two states are protected and answer 409:
- Approved (
409 estimate_delete_signed_record), an approved estimate is a signed legal record (signature, IP, typed name, pinned terms). Decline it or convert it instead. - Converted (
409 estimate_converted), the estimate belongs to its invoice; delete the invoice first.
Scope required: estimates.write
curl -X DELETE https://app.benchkey.com/api/v1/estimates/42 \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"Response
Section titled “Response”{ "object": "estimate", "id": "42", "deleted": true }Send an estimate
Section titled “Send an estimate”POST /api/v1/estimates/:id/sendDelivers the estimate to the customer by SMS, email, or both using the tenant’s estimate template. Sets status to sent and advances the attached ticket to “Quote Sent”.
Scope required: estimates.write
Request body
Section titled “Request body”| Field | Required | Type | Description |
|---|---|---|---|
via | yes | string | Delivery channel: "sms", "email", or "both" |
curl -X POST https://app.benchkey.com/api/v1/estimates/42/send \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: est-42-send-1" \ -d '{ "via": "email" }'Node.js
Section titled “Node.js”const res = await fetch("https://app.benchkey.com/api/v1/estimates/42/send", { method: "POST", headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa", "Content-Type": "application/json", "Idempotency-Key": "est-42-send-1", }, body: JSON.stringify({ via: "email" }),});const estimate = await res.json(); // status: "sent", sent_via: "email"Response
Section titled “Response”Returns the refreshed estimate object with status: "sent" and sent_via set. Returns 404 if the estimate does not exist or its linked ticket is hidden/soft-deleted.
Approve an estimate
Section titled “Approve an estimate”POST /api/v1/estimates/:id/approveRecords staff-side approval of the estimate. Sets status to approved, advances the ticket to “Estimate approved”, and fires the usual notifications and automation. Only a non-approved, non-declined, non-converted estimate can be approved.
Scope required: estimates.write
curl -X POST https://app.benchkey.com/api/v1/estimates/42/approve \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \ -H "Idempotency-Key: est-42-approve-1"Node.js
Section titled “Node.js”const res = await fetch("https://app.benchkey.com/api/v1/estimates/42/approve", { method: "POST", headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa", "Idempotency-Key": "est-42-approve-1", },});const estimate = await res.json(); // status: "approved", approved_at: "..."Response
Section titled “Response”Returns the refreshed estimate object with status: "approved" and approved_at set. Returns 404 if the estimate does not exist or its linked ticket is hidden/soft-deleted.
Decline an estimate
Section titled “Decline an estimate”POST /api/v1/estimates/:id/declineDeclines the estimate. Sets status to declined, flips the ticket back to “Customer Reply”, and fires the usual notifications and automation. Only a sent estimate (one that has a share token) can be declined. An optional reason is recorded on the decline event.
Scope required: estimates.write
Request body
Section titled “Request body”| Field | Required | Type | Description |
|---|---|---|---|
reason | no | string | Optional reason recorded on the decline event (max 200 characters) |
curl -X POST https://app.benchkey.com/api/v1/estimates/42/decline \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: est-42-decline-1" \ -d '{ "reason": "Customer found a cheaper repair shop." }'Node.js
Section titled “Node.js”const res = await fetch("https://app.benchkey.com/api/v1/estimates/42/decline", { method: "POST", headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa", "Content-Type": "application/json", "Idempotency-Key": "est-42-decline-1", }, body: JSON.stringify({ reason: "Customer found a cheaper repair shop." }),});const estimate = await res.json(); // status: "declined", declined_at: "..."Response
Section titled “Response”Returns the refreshed estimate object with status: "declined" and declined_at set. Returns 404 if the estimate does not exist or its linked ticket is hidden/soft-deleted.
Error codes
Section titled “Error codes”| HTTP status | Code | Meaning |
|---|---|---|
400 | invalid_query | A list filter parameter was supplied as an array or object instead of a single scalar value |
400 | invalid_id | The :id is not a valid positive integer |
400 | missing_field | A required field is absent (customer_id, via) |
400 | invalid_field | A field value is invalid, via not in allowed set, unit_price_cents not a positive safe integer, description not a string, unit_price_cents × quantity not a whole number of cents, a single line’s extended price, the aggregate line total, or the tax-pushed final total exceeds the safe-integer range, discount_cents ≥ subtotal, etc. |
422 | send_failed | The estimate could not be delivered, no contact info, or the estimate has no linked ticket (empty/null ticket_id) |
404 | not_found | No estimate with that ID exists, the estimate’s linked ticket is hidden or soft-deleted, or (on create) the supplied ticket_id is hidden/soft-deleted |
422 | create_failed | The customer or ticket could not be found |
409 | estimate_declined | The estimate has already been declined and cannot be approved; create or send a new estimate instead |
422 | approve_failed | The estimate is already approved or converted |
422 | decline_failed | The estimate has not been sent yet (no share token), or is already declined/approved/converted |
403 | insufficient_scope | API key lacks estimates.read or estimates.write |
429 | rate_limited | Too many requests; see Retry-After |
See Errors for the full error envelope format.