Guides

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

Quotes are fully live — request → build → link → accept → pay
Build and issue quotes from the dashboard (Quotes in the app sidebar) or the API, and buyers view their quote at 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 working revision is editable at a time; issuing freezes it (issued) and supersedes the previous offer. Line items support optional upsells and per-line quantity locks (qtyEditable).
  • Payment termspay_in_full, net_terms (Net-1..365), or deposit_balance (deposit due at acceptance, the balance becomes a receivable). Deposit amounts are computed and frozen at issue time.
  • Lifecycledraft → requested → under_review → proposed → accepted → converted, with revision_requested, declined, expired (re-issuable), and archived branches. 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.

Create a draft quote
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"
  }'
Issue it
# 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

Cart → RFQ
# 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.

Quote cart
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.

Buyer amounts are an ask, never a price
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.
Submit a request
# 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.

Buyer accepts via the hosted link
# 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).
Rep records a phone/email acceptance
# 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_received admin.quote_requested
    Quote issued customer.quote_proposed
    Comment added customer.quote_comment_added admin.quote_comment_added
    Revision requested admin.quote_revision_requested
    Accepted customer.quote_accepted admin.quote_accepted
    Declined admin.quote_declined
    Expiring — a warning, before the date customer.quote_expiring (24h) admin.quote_expiring (48h)
    Expired — terminal, when it lapses customer.quote_expired admin.quote_expired

    Note 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 → expired once expiresAt passes (emitting quote.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).
  • PDFGET /api/v1/quote-links/{shareToken}/pdf responds with a 302 to a short-lived signed download URL (filename Q-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 includes orderId.
  • quote.expired — the expiry job flipped a proposed quote past its expiresAt to expired. Re-issue a new revision to reactivate the same link.