Quotes
B2B sales quotes: a buyer requests pricing, a rep builds and issues a revisioned proposal, and acceptance converts directly into a Throttle order with card, Net-N, or deposit + balance payment terms.
Status
https://checkout.usethrottle.dev/q/{shareToken}
— toggle optional lines, comment, request changes, decline, or accept. Accepting
materializes a checkout session and converting it into a Throttle order happens
automatically once payment succeeds. Lifecycle emails, expiry reminders +
auto-expiration, a downloadable PDF, deposit-and-balance terms, and hosted RFQ
intake forms are all live. Sales quotes are unrelated to shipping/tax quote tokens.
Core concepts
- Quote — the negotiation record. Carries a
Q-{seq}number, customer linkage, an optional buyer-supplied reference number (buyerReference), and one share token that will be the buyer's permanent link. - Revision — an immutable commercial snapshot. Exactly one
workingrevision is editable at a time; issuing freezes it (issued) and supersedes the previous offer. Line items supportoptionalupsells and per-line quantity locks (qtyEditable). - Payment terms —
pay_in_full,net_terms(Net-1..365), ordeposit_balance(deposit due at acceptance, the balance becomes a receivable). Deposit amounts are computed and frozen at issue time. - Lifecycle —
draft → requested → under_review → proposed → accepted → converted, withrevision_requested,declined,expired(re-issuable), andarchivedbranches. Transitions are service-enforced.
Create and issue a quote
Requires an API key with the quotes:write scope
(dashboard roles map to application:quotes:*
permissions). Prices are integers in minor units.
curl -X POST https://api.usethrottle.dev/api/v1/quotes \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"customerEmail": "[email protected]",
"buyerReference": "PO-1042",
"items": [
{ "name": "Widget (bulk)", "unitPrice": 1500, "quantity": 200 },
{ "name": "Install service", "unitPrice": 50000, "quantity": 1, "type": "service", "optional": true }
],
"paymentTerms": { "mode": "net_terms", "netN": 30 },
"expiresAt": "2026-08-15T00:00:00Z"
}' # Freeze the working revision and propose it to the buyer.
curl -X POST https://api.usethrottle.dev/api/v1/quotes/{quoteId}/issue \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" -d '{}'
# The response includes quote.shareToken — the buyer link is
# https://checkout.usethrottle.dev/q/{shareToken} (hosted page ships with the
# public-link release).
Editing after issue is explicit: POST
/api/v1/quotes/{id}/revisions clones the issued revision into a new
working revision; PATCH
/api/v1/quotes/{id}/revisions/{revId} edits it; issuing again
supersedes the prior offer at the same buyer link.
Request a quote from a cart
# Turn an existing cart into a quote request without re-keying anything.
# The cart itself is unchanged.
curl -X POST https://api.usethrottle.dev/api/v1/carts/{cartId}/request-quote \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "buyerReference": "PO-1042", "note": "Need delivery by end of month" }' Add to Quote on your storefront
For buyers who want to browse and collect several products before asking, @usethrottle/quotes ships a quote cart: a
persisted list the buyer builds while moving around your site, submitted as one
request. It needs no API key — the qrf_ form token
is the authority, so it is safe in the browser.
Throttle stores no product catalog, so lines come from your own product data, the same way the shopping cart works. Quantities merge when the same SKU is added twice, and the cart survives page navigation.
import { useQuoteCart, useQuoteRequestForm } from '@usethrottle/quotes';
// An Add to Quote button on a product page. Throttle holds no catalog, so the
// line comes from your own product data — exactly like the shopping cart.
function AddToQuote({ product }) {
const { add, count } = useQuoteCart();
return (
<button onClick={() => add({
name: product.title,
sku: product.sku,
referenceId: product.id,
quantity: 1,
})}>
Add to quote ({count})
</button>
);
}
// Anywhere else on the site, submit the accumulated lines as one request.
function SubmitQuoteRequest({ formToken }) {
const { lines, cart, clear } = useQuoteCart();
const { submit, submitting } = useQuoteRequestForm(formToken);
return (
<button
disabled={submitting || lines.length === 0}
onClick={async () => {
await submit({ email: '[email protected]', items: cart.toRequestItems() });
clear();
}}
>
Request a quote for {lines.length} items
</button>
);
}
A buyer who already filled a normal cart can ask for a quote on it instead:
pass cartId to POST /api/v1/quote-requests and its lines join the
request. The cart is resolved inside the form's own application and
environment, so an id from anywhere else is simply not found. Cart prices are
recorded as the buyer's target, not as the quoted price — a cart is assembled
in the browser, and this endpoint authenticates no one but the form.
Hosted request forms
For inbound RFQs without any integration, create a hosted intake form from the
dashboard (Quotes → Request forms) or via POST /api/v1/quote-request-forms. The response
reveals a qrf_ form token exactly once (hash-only stored — rotate to mint a new one); the
buyer-facing form lives at
https://checkout.usethrottle.dev/q/request/{formToken}
. Submissions create-or-find the customer by email, open a requested quote (source request_form) with the buyer's free-text
need as the first shared comment, and fire the same quote.requested webhook + rep notifications as
every other intake path.
A request can carry line items, a written need, or both — at
least one of the two. Items land on the quote's working revision unpriced, carrying the name, SKU, quantity, and per-line note the
buyer supplied, so your rep prices an itemised sheet instead of transcribing prose.
The hosted page renders item rows by default; set fields.lineItems: false for a prose-only form, or fields.targetPrice: true to let buyers name the
price they are after.
targetUnitPrice is recorded against the line and
shown to your rep while they price it, but it never becomes the quoted price.
Everything on this endpoint arrives from a browser holding only a public form
token, so treating any of it as pricing would let a buyer set what you charge.
# A quote request. Items, a written need, or both — one of the two is required.
# No API key: the qrf_ form token is the authority, so this is safe from a browser.
curl -X POST https://api.usethrottle.dev/api/v1/quote-requests \
-H "Content-Type: application/json" \
-d '{
"formToken": "qrf_...",
"email": "[email protected]",
"companyName": "Acme Industrial",
"message": "Need delivery before October.",
"items": [
{ "name": "Wide widget", "sku": "WW-40", "quantity": 400, "targetUnitPrice": 1200,
"note": "Powder coated" },
{ "name": "Install service", "referenceId": "prod_88" }
],
"formLoadedAt": 1754900000000
}'
# Every line lands on the quote's working revision UNPRICED. targetUnitPrice is
# recorded as the buyer's ask and shown to your rep; it never sets what you quote. Bot protection is always on: a honeypot field (trips return a
success-shaped response without creating anything), a minimum-fill-time check, and
per-IP rate limits. Set the form's fields.requireTurnstile flag to additionally
require Cloudflare Turnstile.
Turnstile fails closed. If a form requires it and TURNSTILE_SECRET_KEY is not configured server-side,
submissions are refused with 503 turnstile_not_configured rather than accepted
unverified — a toggle that silently does nothing is worse than no toggle. Forms that do
not require Turnstile are unaffected.
The buyer link
To host this experience on your own storefront instead of Throttle's page, see @usethrottle/quotes
— buyer components, hooks, and an optional same-origin proxy.
Every quote gets one branded, tokenized link — revisions land at the same URL, and
the link keeps working (read-only) after the quote is ordered. Buyers reach it at https://checkout.usethrottle.dev/q/{shareToken}
— no account, no API key. The page shows the itemized offer with live recompute for
optional lines, the revision history, a comment thread, and Request changes / Decline
actions. Branding (logo, accent color, display name) comes from the application's
environment settings, matching hosted checkout.
That host is configurable. Set checkoutLinkBaseUrl on the application and
Throttle builds the buyer link against your domain instead — https://yourstore.com/q/{shareToken} — so
you can serve the quote page yourself with @usethrottle/quotes. Leave it
unset for Throttle-hosted. The same setting governs abandoned-cart recovery links.
-
GET /api/v1/quote-links/{shareToken}— the buyer-shaped read. Draft and archived quotes 404 (their link isn't active yet); internal notes, the working revision, and internal comments are never returned. -
POST …/view,…/comments,…/request-revision,…/decline— all public (no scope), rate-limited per IP and per token, and authenticated purely by possessing the token. -
Rotate a leaked link with
POST /api/v1/quotes/{id}/rotate-link— the old token 404s immediately.
Accept a quote
Acceptance materializes the accepted revision into a cart and creates a checkout
session — the buyer is redirected to checkoutUrl
to pay by card or Net-N, whichever the quote's payment terms allow. Optional
lines can be deselected and quantity-editable lines resized at acceptance time via itemSelections; locked lines and non-optional
lines reject any attempt to change them (
invalid_item_selection). Re-accepting the same
revision is idempotent — it returns the existing checkout session instead of creating
a second one; accepting after the current revision has been superseded returns 409 revision_superseded.
# The buyer accepts straight from the hosted link — no account, no API key.
curl -X POST https://api.usethrottle.dev/api/v1/quote-links/{shareToken}/accept \
-H "Content-Type: application/json" \
-d '{
"revisionId": "{the currently issued revision id}",
"acceptedByName": "Bea Buyer",
"acceptedByEmail": "[email protected]",
"itemSelections": [
{ "itemId": "{optional line id}", "selected": false }
]
}'
# Response: { "data": { "quote": {...}, "checkoutUrl": "https://checkout.usethrottle.dev/s/..." } }
# Redirect the buyer to checkoutUrl to complete payment (card or Net-N,
# depending on the quote's payment terms). # A rep records a phone/email acceptance on the merchant's behalf.
curl -X POST https://api.usethrottle.dev/api/v1/quotes/{quoteId}/accept \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"revisionId": "{the currently issued revision id}",
"acceptedByName": "Bea Buyer",
"acceptedByEmail": "[email protected]",
"reason": "Verbal agreement over the phone"
}' Deposit + balance (deposit_balance
terms): the buyer pays the deposit by card at acceptance — the checkout shows “Due
today” as the deposit, frozen at issue time — and completion automatically creates
a second net30 payment for the balance at Net-
balanceNetN terms, issues the balance invoice, and
drops it into Receivables aging. The order carries two payment rows (deposit captured, balance authorized) and its paymentStatus stays partially_paid until the balance is captured — record
the balance payment with POST
/api/v1/orders/{id}/capture when it settles. Item selections that would
shrink the total to at or below the frozen deposit are rejected.
Payment terms are enforced, not advisory
A checkout session created from an accepted quote pins allowedMethods to what the terms permit, and
completion rejects any other method with 422 payment_method_not_allowed. A pay_in_full quote cannot be settled as a Net-N
receivable.
Negotiated Net-N also outranks the customer's standing default. If a
quote is issued and accepted at Net-60, the invoice is Net-60 even when customers.net_n says otherwise: a signed term for one
deal beats an account-level habit. The resolved value is snapshotted on payments.net_n with net_n_source = 'cart_override'.
Errors
| code | status | when |
|---|---|---|
revision_superseded | 409 |
A newer revision was issued. The response details.currentRevisionId names the live one so
the page can refresh itself.
|
quote_expired | 410 | The accepted revision is past its expiry. |
invalid_item_selection | 400 | Locked-line quantity change, non-optional line deselected, unknown item id, or a deposit quote whose selection fell to or below the deposit. |
accept_in_progress | 409 | A concurrent acceptance is materializing the cart and session. Retry shortly — one quote only ever produces one checkout. |
payment_method_not_allowed | 422 |
Completion attempted with a method the quote's terms exclude. details lists what the session accepts.
|
net_terms_not_configured | 409 | Raised at issue time: the application has no active Invoice Terms connector, so Net-N or deposit terms could never be invoiced. Connect Invoice Terms or switch the quote to pay in full. |
Emails, expiry, and PDF
- Lifecycle emails — sent automatically, branded with the
application's logo/color and from-address (same sender resolution as order
emails). All templates are editable from the dashboard email-template editor.
Moment Buyer Merchant team Quote request submitted customer.quote_request_receivedadmin.quote_requestedQuote issued customer.quote_proposed— Comment added customer.quote_comment_addedadmin.quote_comment_addedRevision requested — admin.quote_revision_requestedAccepted customer.quote_acceptedadmin.quote_acceptedDeclined — admin.quote_declinedExpiring — a warning, before the date customer.quote_expiring(24h)admin.quote_expiring(48h)Expired — terminal, when it lapses customer.quote_expiredadmin.quote_expiredNote the two expiry pairs: expiring warns ahead of the date so a rep can chase, expired reports the lapse. The names are one letter apart and are easy to confuse.
- Active expiry — an hourly job warns the rep 48h before an
unaccepted quote expires, warns the buyer at 24h, and flips
proposed → expiredonceexpiresAtpasses (emittingquote.expired). Each reminder fires once per issued revision — re-issuing re-arms them. A buyer revisiting an expired link notifies the rep in-app (a high-intent re-engagement signal). - PDF —
GET /api/v1/quote-links/{shareToken}/pdfresponds with a 302 to a short-lived signed download URL (filenameQ-1042-rev2.pdf). Rendered lazily from the frozen issued revision — never live pricing — and cached per revision; acceptance re-renders it with the acceptance stamp. The hosted page links to it.
Webhooks
Requires quotes:read. Payloads carry the quote
plus the current revision; internal notes and internal comments are never included.
-
quote.requested— an RFQ is submitted (dashboard, API, or cart). -
quote.proposed— a revision is issued to the buyer. -
quote.viewed— the buyer opens the link. Throttled to one delivery per revision per 6 hours. -
quote.comment_added— a shared comment from either party (internal comments never fire this). -
quote.revision_requested— the buyer asks for changes instead of accepting. -
quote.declined— the buyer declines, with an optional reason. -
quote.accepted— the buyer accepts (or a rep records a phone/email acceptance) and checkout begins. -
quote.converted— the accepted quote completes checkout and its order now exists. Payload includesorderId. -
quote.expired— the expiry job flipped a proposed quote past itsexpiresAtto expired. Re-issue a new revision to reactivate the same link.