Skip to main content

TurboQuote Python SDK

Agent Skill

Let an agent scaffold this for you

Install the TurboDocx Quickstart Skill and let Claude Code, Cursor, Copilot, Codex, or any agent that speaks the Agent Skills standard install the SDK, wire it into your app, and write a working TurboQuote integration end-to-end.

bash — turbodocx
$npx skills add TurboDocx/quickstart
# then, inside your agent:/turbodocx-sdk turboquote

The official TurboDocx TurboQuote SDK for Python applications. Build full CPQ (configure, price, quote) workflows: create quotes, add product and bundle line items, apply price books, send proposals to contacts, and download PDF exports — all from async Python 3.9+. Distributed on PyPI as turbodocx-sdk (same package as TurboSign, TurboWebhooks, and Deliverable).


What is TurboQuote?

TurboQuote is TurboDocx's quoting and proposal engine. It covers the full quote lifecycle — draft, send, accept/decline/void — with a product catalog, bundle groupings, price books, company/contact CRM, and customizable quote templates. Quotes can be sent with an attached Deliverable document for a branded proposal experience.

Installation

pip install turbodocx-sdk

Requirements

  • Python 3.9 or higher
  • httpx (installed automatically as a dependency)
  • A TurboDocx API key (TDX- prefix) — generate one in Settings → API Keys
  • All SDK methods are async — call them from an async def (or wrap with asyncio.run(...) in synchronous contexts)

Configuration

import os
from turbodocx_sdk import TurboQuote

TurboQuote.configure(
api_key=os.environ["TURBODOCX_API_KEY"],
org_id=os.environ["TURBODOCX_ORG_ID"], # optional — auto-reads env var
)
No sender email on the client — but set one on your quote template

TurboQuote.configure() does not take sender_email or sender_name. Quotes are not signature emails. Only api_key and, optionally, org_id are needed. If you skip the explicit call, the SDK lazily auto-configures from TURBODOCX_API_KEY and TURBODOCX_ORG_ID on first use.

The quote's "Prepared by" sender comes from your org quote template instead. Because an API key has no mailbox of its own, every sender-resolving call — create_quote, duplicate_quote, send_quote / send_quote_with_deliverable, and handle_expired_quote — fails with a ValidationError (400 SenderEmailRequired) when the org's quote template has no sender email set. A companion 400 SenderNameRequired is returned when no sender name resolves. Configure both Sender Name and Sender Email once (TurboQuote.update_template(id, {"senderEmail": ..., "senderName": ...})) and all of them resolve cleanly.

Environment Variables

TURBODOCX_API_KEY=your_api_key_here
TURBODOCX_ORG_ID=your_org_id_here
# optional — defaults to https://api.turbodocx.com
TURBODOCX_BASE_URL=https://api.turbodocx.com

Quick Start

Create a quote, add line items, send, and download the PDF

import asyncio
import os
from turbodocx_sdk import TurboQuote

TurboQuote.configure(
api_key=os.environ["TURBODOCX_API_KEY"],
org_id=os.environ["TURBODOCX_ORG_ID"],
)


async def full_quote_lifecycle():
# 1. Create a draft quote
quote = await TurboQuote.create_quote({
"name": "Acme Corp — Annual Plan Q3",
"companyId": "company-uuid",
"contactId": "contact-uuid",
"validUntil": "2026-09-30",
"currency": "USD",
})
quote_id = quote["id"]
print(f"Created quote {quote_id}")

# 2. Add product line items
items = await TurboQuote.add_line_items(quote_id, [
{
"productId": "product-uuid-1",
"productName": "Platform License",
"quantity": 5,
"unitPrice": "199.00",
"billingFrequency": "annual",
},
{
"productId": "product-uuid-2",
"productName": "Onboarding Package",
"quantity": 1,
"unitPrice": "499.00",
"billingFrequency": "one-time",
},
])
print(f"Added {len(items)} line items")

# 3. Apply a price book (optional)
result = await TurboQuote.apply_price_book(quote_id, "pricebook-uuid")
print(f"Price book applied: {result['updatedCount']} items updated")

# 4. Send the quote
sent = await TurboQuote.send_quote(quote_id, {
"ccEmails": ["manager@acme.com"],
"validUntil": "2026-09-30",
})
print(f"Sent: {sent['message']}")

# 5. Download the PDF
pdf_bytes = await TurboQuote.download_quote_pdf(quote_id)
with open("acme-quote.pdf", "wb") as f:
f.write(pdf_bytes)
print(f"PDF saved ({len(pdf_bytes)} bytes)")


asyncio.run(full_quote_lifecycle())

Convenience: create, add items, and send in one call

result = await TurboQuote.create_and_send({
"name": "Acme Corp — Quick Proposal",
"companyId": "company-uuid",
"contactId": "contact-uuid",
"currency": "USD",
"items": [
{"productId": "product-uuid-1", "productName": "Platform License", "quantity": 3, "unitPrice": "299.00", "billingFrequency": "monthly"},
],
"bundleItems": [
{"bundleId": "bundle-uuid-1", "bundleName": "Starter Pack", "quantity": 1},
],
"send": {
"ccEmails": ["cc@example.com"],
"validUntil": "2026-09-30",
},
})
print(f"Quote sent: {result['quote']['id']}")

Method Reference

All methods are @classmethods on TurboQuote; configure once, then call on the class.

Quotes

list_quotes

page = await TurboQuote.list_quotes({
"limit": 20,
"offset": 0,
"query": "acme",
"statuses": ["draft", "sent"], # repeated-key filter
})
# page["results"], page["totalRecords"], page["stats"]

for q in page["results"]:
print(q["name"], q["status"])

create_quote

# Fixed-term quote — termDays is -1 or 0–3650; omit it to get the default of 60.
quote = await TurboQuote.create_quote({
"name": "Q3 Proposal",
"companyId": "company-uuid", # required
"contactId": "contact-uuid", # required
"currency": "USD",
"termDays": 30, # fixed term — do NOT send renewalPeriod with this
"validUntil": "2026-09-30",
"notes": "Includes implementation services",
"taxRate": "8.5",
})
# returns Quote dict

# Auto-renewal quote — termDays -1 REQUIRES renewalPeriod.
subscription = await TurboQuote.create_quote({
"name": "Annual Subscription",
"companyId": "company-uuid",
"contactId": "contact-uuid",
"currency": "USD",
"termDays": -1, # -1 = auto-renewal
"renewalPeriod": "annually", # "weekly" | "monthly" | "quarterly" | "annually"
})
termDays and renewalPeriod are coupled

termDays defaults to 60 when omitted. Valid values are -1 (auto-renewal) or 03650 (0 = one-time).

renewalPeriod is required when termDays is -1, and must be None or absent for every other termDays value — sending it alongside a fixed term returns a 400. The same rule applies on update_quote.

get_quote

Returns the quote with statusInfo and preparedBy merged in when present. preparedBy is the resolved "Prepared by" identity shown on the quote PDF.

quote = await TurboQuote.get_quote("quote-uuid")
# quote["status"], quote["grandTotal"], quote.get("statusInfo")
prepared = quote.get("preparedBy") or {}
print(prepared.get("name")) # e.g. "Acme Billing Integration" or the template sender
print(prepared.get("email")) # may be absent for an API-created quote — render a placeholder

preparedBy is resolved server-side (org template first, then the quote's creator). Prefer it over creator for any customer-facing display — creator may be the internal API service account. For an API-created quote the resolved name is the API key's name (never a generic "API Service User"), and the email comes from the org quote template. preparedBy is returned by the single-quote fetch only — it is not present on create, duplicate, or list responses.

update_quote

All fields are optional — pass only what changes. Send an explicit None to clear a nullable field.

updated = await TurboQuote.update_quote("quote-uuid", {
"name": "Q3 Proposal — Revised",
"validUntil": "2026-10-15",
"taxRate": None, # clears the field
})

delete_quote

result = await TurboQuote.delete_quote("quote-uuid")
# result["message"]

duplicate_quote

new_quote = await TurboQuote.duplicate_quote("quote-uuid")
# returns new Quote in draft status

The copy is attributed to whoever ran the duplicate, not to the original quote's creator — duplicating with an API key produces a quote whose "Prepared by" resolves through that API key and your org quote template.

download_quote_pdf

Returns raw PDF bytes.

pdf_bytes = await TurboQuote.download_quote_pdf("quote-uuid")
with open("quote.pdf", "wb") as f:
f.write(pdf_bytes)

Quote Numbering Configuration

Customize the per-org quote number format: prefix, year/month tokens, separator, zero-padding, suffix, starting number, and reset cadence. Both methods are admin only; a non-admin API key receives a 403.

get_quote_number_config

Fetch the org's current quote numbering format and the current per-period issued floor.

config = await TurboQuote.get_quote_number_config()
print(config["format"]["prefix"]) # e.g. "Q-"
print(config["currentFloor"]) # the current per-period issued floor

update_quote_number_config

Update the numbering format. Pass the full format object; all eight fields are required. Keys stay camelCase.

config = await TurboQuote.update_quote_number_config({
"prefix": "INV",
"yearToken": "none", # "none" | "two" | "four"
"monthToken": "off", # "off" | "two"
"separator": "-",
"padWidth": 4, # 0–12
"suffix": "",
"startNumber": 1000, # >= 0
"resetCadence": "never", # "never" | "yearly" | "monthly"
})
print(config["format"]["startNumber"]) # 1000

Field reference, defaults & validation

All eight format keys are sent on every update. The API enforces these caps and allowed values — a violation returns 400:

FieldTypeAllowed / rangeDefault
prefixstring≤ 12 characters"Q"
yearTokenenumnone | two | fourfour
monthTokenenumoff | twooff
separatorstring≤ 4 characters"-"
padWidthinteger012 (0 = no padding)5
suffixstring≤ 12 characters""
startNumberinteger010000000001
resetCadenceenumnever | yearly | monthlyyearly

An org that has never configured numbering uses the default format above, which renders like Q-2026-00001.

Beyond the per-field caps, the API rejects self-inconsistent formats with a 400:

  • resetCadence: "yearly" requires a year token (yearToken other than none) — otherwise numbers repeat across years.
  • resetCadence: "monthly" requires both a year token and a month token (monthToken: "two").
  • The rendered quote number must be ≤ 256 characters.

currentFloor (returned by both methods) is read-only — the sequence the next quote will use for the current period — and is never sent on update.


Quote Status Transitions

Send preconditions

Both send methods share the same server-side checks. Each is rejected with HTTP 400 and a specific error code before anything is created or emailed:

ConditionCode
Quote is not a draftQuoteNotSendable
No validUntil date setQuoteValidUntilRequired
validUntil is in the pastQuoteExpired
No line itemsQuoteHasNoLineItems
Contact missing a name or emailQuoteContactRequired
Company or contact deleted/deactivatedQuoteCustomerInactive
No sender email resolvable (API-key callers)SenderEmailRequired

A quote with no line items cannot be sent — add at least one product, bundle, or custom line item first. Likewise an expired quote is rejected; update validUntil, or use the handle-expired flow to void it and create a fresh draft.

send_quote

sent = await TurboQuote.send_quote("quote-uuid", {
"ccEmails": ["manager@example.com"],
"validUntil": "2026-09-30",
})
# sent["quote"], sent["message"]

send_quote_with_deliverable

Attach a TurboDocx-generated document to the sent quote.

result = await TurboQuote.send_quote_with_deliverable("quote-uuid", {
"deliverableId": "deliverable-uuid",
"ccEmails": ["manager@example.com"],
})
# result["quote"], result["message"], result["documentId"]

decline_quote

quote = await TurboQuote.decline_quote("quote-uuid", {
"reason": "Customer selected a competitor",
})

void_quote

quote = await TurboQuote.void_quote("quote-uuid", {
"reason": "Terms expired before acceptance",
})

handle_expired_quote

Handles a quote that has passed its validUntil date. The endpoint closes out the original quote — voiding or declining it depending on action — and then creates a duplicate carrying newValidUntil as its new validity date. The returned quote is the new duplicate; the original stays terminal.

All three keys are required: action ("void" or "decline"), reason (max 190 characters), and newValidUntil (ISO date).

quote = await TurboQuote.handle_expired_quote("quote-uuid", {
"action": "void", # required — "void" or "decline" only
"reason": "Quote expired", # required — max 190 characters
"newValidUntil": "2026-12-31", # required — ISO date carried onto the duplicate
})
There is no extend or resend action

action accepts only "void" and "decline". "extend" and "resend" do not exist in the API and return a 400. Extending is what the endpoint already does — pass newValidUntil and it lands on the duplicate it creates.

Terminal statuses

accepted, declined, and voided are terminal — a quote in one of these states cannot be transitioned out of it, and any further status call returns a 400. Check quote["statusInfo"] before attempting a transition, and use duplicate_quote when you need to revive a closed-out quote.


Price Books (on a Quote)

apply_price_book

Apply a price book to an existing quote; updates matching line item prices.

result = await TurboQuote.apply_price_book("quote-uuid", "pricebook-uuid")
# result["quote"] — updated Quote
# result["updatedCount"] — int, items that were re-priced
# result["skippedCount"] — int, items that had no pricebook match
# result["message"]

remove_price_book

quote = await TurboQuote.remove_price_book("quote-uuid")

Line Items

list_line_items

page = await TurboQuote.list_line_items("quote-uuid", {"limit": 50})
# page["results"], page["totalRecords"]

add_line_items

Accepts a single item dict or a list of up to 50 items. Returns a list of created LineItem dicts.

productId, productName, unitPrice, and billingFrequency are all required on every row. productId is special: the key must be present, but its value may be None for a custom (freeform) line item — omitting the key entirely returns a 400. quantity is optional and defaults to 1.

items = await TurboQuote.add_line_items("quote-uuid", [
{
"productId": "product-uuid", # required key — None for a custom line item
"productName": "Platform License",
"quantity": 2,
"unitPrice": "199.00",
"billingFrequency": "annual",
"discountPercent": "10.0",
},
{
"productId": None, # custom (freeform) line item — key still required
"productName": "Implementation Credit",
"unitPrice": "-250.00",
"billingFrequency": "one-time",
},
])
List caps

add_line_items accepts a single dict or a list of 1–50 items. A reorder request accepts up to 200 items. Exceeding either cap returns a 400.

add_bundle_line_items

bundleId and bundleName are both required; the server expands the bundle's child products for you. Accepts a single dict or a list of up to 50 items.

items = await TurboQuote.add_bundle_line_items("quote-uuid", [
{
"bundleId": "bundle-uuid",
"bundleName": "Starter Bundle", # required
"quantity": 1,
}
])

update_line_item

item = await TurboQuote.update_line_item("quote-uuid", "item-uuid", {
"quantity": 5,
"unitPrice": "179.00",
})

remove_line_item

result = await TurboQuote.remove_line_item("quote-uuid", "item-uuid")
# result["message"]

Products

MethodSignatureReturns
list_products(options?)paginated list
create_product(request)Product
get_product(id)Product
update_product(id, request)Product
delete_product(id){"message": ...}
duplicate_product(id)Product
get_product_primary_images(product_ids){id: image | None}
categoryId is required on create

create_product requires name, categoryId, listPrice, and billingFrequency. categoryId must be the UUID of an existing type (categoryType: "product_category") — resolve or create it first with list_types / create_type. It is optional on update_product, so you only need to pass it when creating.

# Resolve the product category first — create_product needs its UUID.
page = await TurboQuote.list_types({"categoryType": "product_category"})
category = next((t for t in page["results"] if t["name"] == "Software"), None)
if category is None:
category = await TurboQuote.create_type({
"name": "Software",
"categoryType": "product_category",
})

# Create a product with images (multipart upload auto-detected)
product = await TurboQuote.create_product({
"name": "Enterprise License",
"categoryId": category["id"], # required
"listPrice": "499.00",
"cost": "200.00",
"billingFrequency": "annual",
"showInCatalog": True,
"images": ["/path/to/product-photo.png"], # file path(s) or bytes
})

# List products with filters
page = await TurboQuote.list_products({
"limit": 20,
"query": "enterprise",
"showInCatalog": True,
})

# Bulk fetch primary images for a set of product IDs
images = await TurboQuote.get_product_primary_images(["id-1", "id-2", "id-3"])
# images["id-1"] → ProductImage dict, or None

Bundles

MethodSignatureReturns
list_bundles(options?)paginated list
create_bundle(request)Bundle
get_bundle(id)Bundle
update_bundle(id, request)Bundle
delete_bundle(id){"message": ...}
duplicate_bundle(id)Bundle

Each entry in items requires productId, unitPrice, and billingFrequency. quantity is optional and defaults to 1.

bundle = await TurboQuote.create_bundle({
"name": "Starter Pack",
"items": [
{
"productId": "product-uuid-1",
"unitPrice": "199.00",
"billingFrequency": "monthly",
"quantity": 1,
},
{
"productId": "product-uuid-2",
"unitPrice": "49.00",
"billingFrequency": "monthly",
"quantity": 2,
},
],
"discountPercent": "5.0",
})

Price Books

MethodSignatureReturns
list_price_books(options?)paginated list
create_price_book(request)PriceBook
get_price_book(id)PriceBook
update_price_book(id, request)PriceBook
delete_price_book(id){"message": ...}
duplicate_price_book(id)PriceBook
list_price_book_products(id, options?)paginated list
# name, priceBookTypeId, and validFrom are REQUIRED.
# discountPercent is optional and defaults to 0 if not provided.
# priceBookTypeId comes from a create_type with categoryType "pricebook_type".
pricebook = await TurboQuote.create_price_book({
"name": "Partner Tier A",
"priceBookTypeId": "pricebook-type-uuid",
"validFrom": "2026-01-01",
"discountPercent": 15,
"isDefault": False,
"productPricing": [
{"productId": "product-uuid-1", "discountType": "percent", "discountPercent": 25},
{"productId": "product-uuid-2", "discountType": "percent", "discountPercent": 30},
],
})

# List products attached to a price book
page = await TurboQuote.list_price_book_products("pricebook-uuid", {"limit": 50})

Companies

MethodSignatureReturns
list_companies(options?)paginated list
create_company(request)Company
get_company(id)Company
update_company(id, request)Company
delete_company(id){"message": ...}
list_company_contacts(company_id, options?)paginated list
company = await TurboQuote.create_company({
"name": "Acme Corporation",
"domain": "acme.com",
"contacts": [ # at least one contact required
{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@acme.com",
}
],
})

# List contacts for a company
contacts = await TurboQuote.list_company_contacts(company["id"])

Contacts

MethodSignatureReturns
list_contacts(options?)paginated list
create_contact(request)Contact
update_contact(id, request)Contact
delete_contact(id){"message": ...}
No get_contact by design

The backend has no GET /v1/contacts/:id endpoint. Fetch individual contacts via list_company_contacts or list_contacts with a query filter.

contact = await TurboQuote.create_contact({
"firstName": "John",
"lastName": "Smith",
"email": "john@acme.com",
"companyId": "company-uuid",
})

Quote Templates

MethodSignatureReturns
list_templates(options?)paginated list
get_template()singleton QuoteTemplate (auto-created if none exists)
get_template_by_id(id)QuoteTemplate
create_template(request)QuoteTemplate400 if one already exists
update_template(id, request)QuoteTemplate
delete_template(id){"message": ...} — resets to org branding defaults
Templates are auto-provisioned — use get_template()update_template()

get_template() self-heals: if the org has no template, the API creates one from your org branding and returns it. Every established org therefore already has a template, which means:

  • create_template() returns 400 TEMPLATE_ALREADY_EXISTS and is effectively unreachable. Do not build a get-then-create flow.
  • delete_template() is really "reset to org branding defaults" — it soft-deletes, and the next get_template() regenerates a fresh one.

The correct flow is get_template()update_template().

# 1. Get the org's quote template (created from org branding on first read)
template = await TurboQuote.get_template()

# 2. Brand it by updating the template you just fetched
branded = await TurboQuote.update_template(template["id"], {
"logoUrl": "https://cdn.example.com/logo.png",
"primaryColor": "#0057b8",
"senderName": "TurboDocx Sales",
})

# Fetch a specific named template by ID
other = await TurboQuote.get_template_by_id("template-uuid")

Types / Categories

MethodSignatureReturns
list_types(options?)paginated list
create_type(request)QuoteType
update_type(id, request)QuoteType
delete_type(id){"message": ...}
No get_type by design

The backend has no GET /v1/types/:id endpoint. Use list_types to retrieve individual type records.

quote_type = await TurboQuote.create_type({"name": "New Business"})

await TurboQuote.update_type(quote_type["id"], {"name": "New Logo Business"})

Bulk Imports

Every create-family entity has a matching bulk_create_* method for seeding a catalog or migrating CRM data in one call. Each method sends POST {resource}/bulk with a list of row dicts using the same shape as that entity's single-create request — keys stay camelCase, even in Python. Company rows require a contacts list with at least one contact; contact rows require a companyId.

Rows process sequentially with partial success — a failed row does not raise and does not roll back earlier rows. Every bulk method returns a BulkImportResult dict:

  • imported — count of rows created
  • failed — list of {"row": ..., "reason": ...} for rows that did not import; row is the 1-indexed position in your request list
  • adjusted — list of {"row": ..., "reason": ...} for rows that imported with a server-side adjustment (e.g. a bundle item whose product wasn't found was dropped)

Requests are capped at 500 rows — anything above the cap returns a 400. Available to admin and contributor API keys.

Product rows require a real categoryId

Every bulk_create_products row requires name, categoryId, listPrice, and billingFrequency. categoryId must be the UUID of an existing type (categoryType: "product_category") — there is no categoryName key on the bulk row schema, and the API rejects unknown keys, so passing one returns a 400. Resolve or create the category first with list_types / create_type, then pass its id.

# 1. Resolve the product category first — bulk rows need its UUID, not its name.
page = await TurboQuote.list_types({"categoryType": "product_category"})
category = next((t for t in page["results"] if t["name"] == "Software"), None)
if category is None:
category = await TurboQuote.create_type({
"name": "Software",
"categoryType": "product_category",
})

# 2. Import, passing the resolved UUID on every row.
result = await TurboQuote.bulk_create_products([
{"name": "Enterprise License", "categoryId": category["id"], "listPrice": "499.00", "billingFrequency": "annual"},
{"name": "Onboarding Package", "categoryId": category["id"], "listPrice": "999.00", "billingFrequency": "one-time"},
])

print(f"Imported {result['imported']} of 2 rows")
for failure in result["failed"]:
print(f"Row {failure['row']} failed: {failure['reason']}")
for adjustment in result["adjusted"]:
print(f"Row {adjustment['row']} imported with adjustment: {adjustment['reason']}")

The other five bulk methods follow the exact same pattern:

MethodRows
bulk_create_price_bookslist of create_price_book dicts
bulk_create_bundleslist of create_bundle dicts
bulk_create_companieslist of create_company dicts — each needs contacts (min. 1)
bulk_create_contactslist of create_contact dicts — each needs companyId
bulk_create_typeslist of create_type dicts

Convenience

create_and_send

Orchestrates multiple API calls: create quote → add product items → add bundle items → send. Returns {"quote": <sent Quote>}.

result = await TurboQuote.create_and_send({
# CreateQuote fields
"name": "Year-End Renewal",
"companyId": "company-uuid",
"contactId": "contact-uuid",
"currency": "USD",
# optional line items added before send
"items": [
{"productId": "product-uuid-1", "productName": "Platform License", "quantity": 10, "unitPrice": "99.00", "billingFrequency": "monthly"},
],
# optional bundle items added before send
"bundleItems": [
{"bundleId": "bundle-uuid-1", "bundleName": "Starter Pack", "quantity": 1},
],
# send options (ccEmails, validUntil)
"send": {
"ccEmails": ["finance@example.com"],
"validUntil": "2026-12-31",
},
})
print(result["quote"]["id"])

Error Handling

from turbodocx_sdk import (
TurboDocxError,
AuthenticationError,
AuthorizationError,
ValidationError,
NotFoundError,
ConflictError,
RateLimitError,
NetworkError,
)

try:
quote = await TurboQuote.create_quote({
"name": "Q3 Proposal",
"companyId": "company-uuid",
"contactId": "contact-uuid",
})
except ValidationError as e:
# 400 — missing required fields, invalid enum value, etc.
print(f"Validation failed: {e}")
except AuthenticationError:
# 401 — bad or revoked API key
pass
except AuthorizationError:
# 403 — key valid but lacks permission for this org/action
pass
except NotFoundError as e:
# 404 — company, contact, or product not found
print(f"Not found: {e}")
except RateLimitError:
# 429 — back off and retry
pass
except NetworkError:
# request never reached the server (DNS, refused, timeout)
pass
except TurboDocxError as e:
# catch-all for any other typed SDK error (raw 5xx, etc.)
print(f"Error {getattr(e, 'status_code', '?')}: {e}")

Error Code Reference

StatusClassWhen
400ValidationErrorInvalid fields, missing required keys, bad enum value
401AuthenticationErrorMissing or invalid API key
403AuthorizationErrorValid key without permission for this operation
404NotFoundErrorQuote, product, company, or other resource not found
409ConflictErrorDuplicate resource (e.g., company domain conflict)
429RateLimitErrorRate limit exceeded — back off

See Also