React Package · @usethrottle/quotes
Buyer-facing components, hooks, and a same-origin proxy for the hosted quote link and the RFQ request form — so you can serve the buyer experience from your own storefront instead of sending people to Throttle's hosted page.
qtl_ capability
token in the buyer's URL — there is no API key, because this runs in the browser.
Merchant operations (create, price, issue, revise, record acceptance) need an sk_* key and live in @usethrottle/api-client's QuotesService
, which must stay server-side.
Install
pnpm add @usethrottle/quotes
React 18+ is a peer dependency. The package ships ESM and CJS builds with type
declarations, and exposes two entrypoints: the root (browser) and /server (the optional proxy).
The buyer quote page
Mount QuoteView on a route that receives the token
from the buyer's emailed link.
// app/q/[token]/page.tsx
import { QuoteView } from '@usethrottle/quotes';
export default function QuotePage({ params }: { params: { token: string } }) {
return <QuoteView token={params.token} />;
} It renders line items with optional toggles and editable quantities, live totals, revision history, the comment thread, a PDF link, and the accept / request-changes / decline actions. On acceptance it sends the buyer to Throttle hosted checkout.
The default markup is deliberately plain — it is meant to be restyled or replaced. For your own presentation, use the hook directly.
useQuote(token, options?)
import { useQuote, formatMoney } from '@usethrottle/quotes';
function MyQuote({ token }: { token: string }) {
const { quote, total, selections, setQuantity, setSelected, accept, error } = useQuote(token);
if (!quote) return null;
return (
<>
{quote.currentRevision?.items.map((item) => (
<Row
key={item.id}
item={item}
state={selections[item.id]}
onQty={(q) => setQuantity(item.id, q)}
onToggle={(on) => setSelected(item.id, on)}
/>
))}
{/* Always render `total` — never sum the lines yourself. */}
<strong>{formatMoney(total, quote.currency)}</strong>
<button
onClick={async () => {
const { checkoutUrl } = await accept({
acceptedByName: 'Ada Lovelace',
acceptedByEmail: '[email protected]',
});
window.location.href = checkoutUrl;
}}
>
Accept & checkout
</button>
</>
);
} The hook owns the behaviour that is easy to get wrong:
-
Fires the view beacon once per mount, so the merchant sees first viewed telemetry. The API throttles the resulting
quote.viewedwebhook to one per revision per 6 hours. -
Seeds
selectionsfrom the revision, so a re-issue that adds or locks lines resets the buyer to the new offer. -
Refetches automatically on
revision_superseded— the rep re-issued while the buyer was reading. - Sends only the selections the rep actually allows: locked lines and non-optional lines are never included in the acceptance payload.
total is computed with the same rule the server applies at acceptance, including scaling a
line's tax and discount pro-rata when the buyer changes its quantity. Rolling your
own sum risks showing the buyer one number and charging another.
Errors
Everything throws ThrottleQuotesError, which extends
the shared ThrottleError and carries the API's
own code.
import { ThrottleQuotesError } from '@usethrottle/quotes';
try {
await accept({ acceptedByName, acceptedByEmail });
} catch (e) {
if (e instanceof ThrottleQuotesError) {
if (e.isExpired) return showExpiredNotice();
// useQuote already refetched; e.currentRevisionId names the live revision.
if (e.isSuperseded) return showRefreshedPricing();
console.error(e.code, e.statusCode, e.message);
}
} | code | meaning |
|---|---|
revision_superseded |
A newer revision was issued. currentRevisionId
names it; useQuote auto-refreshes.
|
quote_expired | The offer lapsed (410). Prompt the buyer to request updated pricing. |
invalid_item_selection | A locked line's quantity was changed, a required line deselected, an unknown item sent, or (for deposit terms) the selection fell to or below the deposit. |
accept_in_progress | A concurrent acceptance won the race. Retry shortly. |
not_found | Unknown or rotated token, or a draft/archived quote. |
RFQ request form
// app/q/request/[formToken]/page.tsx
import { QuoteRequestForm } from '@usethrottle/quotes';
export default function RequestPage({ params }: { params: { formToken: string } }) {
return <QuoteRequestForm formToken={params.formToken} />;
} Bot protection is wired in: the honeypot field and the mount-time anchor that feeds the API's minimum-fill-time check. A honeypot hit returns a success-shaped response and creates nothing — by design, so a bot cannot learn it was caught. Treat submission success as accepted, not created.
If the form requires Cloudflare Turnstile, render the widget yourself and pass its token
as turnstileToken — this package pulls in no
third-party script. A form that requires Turnstile on a store where it is not configured
is refused with turnstile_not_configured rather than
silently accepted.
Same-origin proxy · /server entrypoint
Quote links work fine calling Throttle directly — the token is the credential. Proxy through your own origin if you would rather not have a third-party host in the address bar or CSP.
// app/api/throttle/[...path]/route.ts
import { createQuoteProxyHandler } from '@usethrottle/quotes/server';
const handler = createQuoteProxyHandler();
export { handler as GET, handler as POST }; <QuoteView token={token} options={{ baseUrl: 'https://yourshop.com/api/throttle' }} />
The proxy forwards no API key. It allow-lists the public quote paths, so
it cannot be pointed at a merchant endpoint; rejects traversal and any method other than
GET/POST; passes the PDF 302 straight through instead
of buffering the file; and forwards x-forwarded-for so acceptance evidence and per-IP
limits attribute to the real buyer.
Payment terms
A quote carries one of three modes on currentRevision.paymentTerms:
-
pay_in_full— card at checkout. -
net_terms— invoice due innetNdays. -
deposit_balance—depositAmountcharged today by card, the remainder invoicedbalanceNetNdays out.
depositAmount is frozen when the rep issues the
quote, so it does not move when the buyer edits quantities. Selections that would drop the
total to or below the deposit are rejected with invalid_item_selection.