Line Resolvers
A line resolver lets an installed extension own cart lines. Throttle never takes a price from the browser for a line your extension owns: it asks your resolver for the name, price, stock and limits on every add, quantity change, sign-in merge and checkout.
What a line resolver is
Your extension declares an HTTPS endpoint, lineResolverUrl.
A storefront (or a merchant server) creates a cart with lineResolver: { extensionId }, which makes it a resolved cart. Lines added to it by referenceId
are owned by your installation, and Throttle calls your resolver to price them.
- Opt in, per cart. Only a cart created with
lineResolveris resolved. Every other cart behaves exactly as before: same status codes, same error envelopes, same response bodies, same webhooks. The one exception is an environment that turns on resolved lines only: there, unresolved cart-session carts are locked too. - One owner per line. Each owned line records the installation that priced it, so a cart can hold lines from two resolvers after a sign-in merge, and each line is re-priced by its own resolver.
- Fail closed. If your resolver cannot answer, the operation is refused and nothing on the cart changes. Throttle never falls back to a browser price.
sequenceDiagram
participant B as Browser or server
participant T as Throttle
participant R as Your line resolver
B->>T: add { referenceId, quantity }
T->>R: POST lineResolverUrl (signed cart.lines.resolve)
R-->>T: { lines: [ { ok, name, unitPrice, ... } ] }
T-->>B: cart with the line at your price
Note over T,R: no answer in 3 s = 503, nothing changes Shoppers create resolved carts through cart sessions; servers through the Cart API. This page is the contract for the resolver itself.
Declare a resolver: lineResolverUrl
lineResolverUrl is a manifest field on the extension,
accepted by POST /api/v1/extensions and PATCH /api/v1/extensions/:id. A version snapshots it like
any other manifest field, and an installation uses the URL of the version it is pinned to.
curl -X POST https://api.usethrottle.dev/api/v1/extensions \
-H "Authorization: Bearer <dashboard-session>" \
-H "content-type: application/json" \
-d '{
"name": "Product Catalog",
"slug": "product-catalog",
"scopes": ["orders:read", "customer_tokens:read"],
"webhookUrl": "https://catalog.example.com/throttle/webhooks",
"eventSubscriptions": ["order.created"],
"lineResolverUrl": "https://catalog.example.com/throttle/line-resolver"
}'
# Remove the resolver from the draft later (allowed only with PATCH):
curl -X PATCH https://api.usethrottle.dev/api/v1/extensions/$EXTENSION_ID \
-H "Authorization: Bearer <dashboard-session>" \
-H "content-type: application/json" \
-d '{ "lineResolverUrl": null }' | Rule | What happens otherwise |
|---|---|
An https: URL on the default port (443). An explicit :443 is fine; any other port is not. | 400 validation_error: lineResolverUrl must be an https URL on the default port (443). |
| A DNS host name: no IP address (IPv4 or IPv6, in any notation), no localhost or *.localhost, no trailing dot. | Same 400 validation_error. |
| No user name or password in the URL. | Same 400 validation_error. |
Requires webhookUrl and at least one entry in eventSubscriptions. Resolver calls are signed with the managed webhook endpoint's signing secret, which only exists for an extension that receives events. | 422 line_resolver_requires_webhook. The check runs on the stored extension merged with the PATCH, so a PATCH cannot drop the webhook side and strand a resolver. |
PATCH with "lineResolverUrl": null removes the resolver. After that, eventSubscriptions: [] is allowed again. | — |
- Domain-verified at review. Marketplace review checks the resolver
URL's registrable domain against your workspace's verified domains, exactly as it
does for
iframeUrlandwebhookUrl(domain_not_verifiedotherwise). - A new domain forces review. A version whose resolver domain differs from
the approved one is flagged
changed_line_resolver_domainand needs review again. Adding a resolver to an approved extension is a domain change too. See Publishing. - Signing secret. The resolver is signed with the same secret your installation receives for webhooks. Rotate it with the webhook secret rotation; resolver calls follow the same grace window.
422 line_resolver_not_available and an existing resolved
cart answers 503 line_resolver_unavailable, both with reason: "no_signing_secret". It is never called unsigned.
Which installation is called
lineResolver.extensionId on cart creation names your
extension. Throttle maps it to that extension's installation in the cart's
application and environment, and stores the installation on the cart. Creation is refused
with 422 line_resolver_not_available and details.reason:
| reason | Meaning |
|---|---|
not_installed | The extension has no installation in this application and environment. |
installation_inactive | The installation exists but is not active (for example, suspended). |
no_resolver_url | The pinned version declares no lineResolverUrl. |
no_signing_secret | The installation has no active managed webhook endpoint linked to it. |
external_cart_provider | The application uses an external cart provider. Resolved carts are native carts only. |
not_configured | Resolved carts are not enabled on this deployment. |
A resolved cart's currency must be the environment's currency (case-insensitive),
or creation answers 422 currency_not_enabled with details: { currency, enabledCurrencies }. A cart
never changes currency: to sell in another one, start a new resolved cart. If the
installation later goes away, is suspended, or loses its URL or secret, calls on its
existing carts fail closed with 503 line_resolver_unavailable
and the same details.reason.
The request
Throttle sends one POST per resolver per operation, with a
JSON body of type cart.lines.resolve.
POST /throttle/line-resolver HTTP/1.1
Host: catalog.example.com
Content-Type: application/json
User-Agent: Throttle-LineResolver/1
X-Throttle-Resolver-Signature: t=1760000000,v1=5f0c…
{
"type": "cart.lines.resolve",
"id": "4b0f3a52-8f6e-4c4f-9d0a-3c1b2e7d9a10",
"installationId": "62e32355-72ed-4bf6-b738-fcd554a3e851",
"applicationId": "7f9d4c8a-5b2e-4f16-9a73-2d1e5c8b6f40",
"environmentId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"cartId": "0f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a",
"intent": "add",
"currency": "USD",
"customerId": null,
"customerEmailVerified": false,
"locale": "en-US",
"lines": [
{ "lineId": null, "referenceId": "TSHIRT-BLUE-M", "quantity": 2 }
]
} | Field | Meaning |
|---|---|
type | Always cart.lines.resolve. |
id | A UUID for this request. The checkout retry sends the same body, so the same id. |
installationId | Your installation. Use it to find the merchant catalog to price from. |
applicationId / environmentId | The cart's application and workspace environment. |
cartId | The Throttle cart. On a create it is the id the new cart will get. |
intent | Why Throttle is asking: add, update, refresh or checkout (below). |
currency | The cart currency. Every ok line must answer in it. |
customerId | The signed-in customer of the cart, or null for a guest cart. |
customerEmailVerified | True when that customer has verified the email on their storefront account. |
locale | The buyer locale given at cart creation (for example en-GB), or null. |
lines | 1 to 50 lines, each { lineId, referenceId, quantity }. referenceId appears once per request. quantity is the quantity the line will have, not an increment. lineId is the Throttle line id, or null for a line not in the cart yet. |
| intent | Sent when | Lines |
|---|---|---|
add | A cart is created with lineItems, or a line is added. Adding a SKU the cart already holds merges into that line. | The new lines; for a repeat SKU, its existing quantity plus the added one. |
update | The quantity of an owned line changes. | That one line. |
refresh | An explicit refresh (POST /api/v1/cart-sessions/:id/refresh), a sign-in claim or secret-key merge, and pricing a customer's other-currency cart before a merge. | Every line your installation owns in the cart. |
checkout | The cart-session hand-off to checkout. | Every line your installation owns in the cart. |
A cart holds at most 50 owned lines per resolver, so one request never
exceeds 50 lines. The 51st distinct SKU is refused with 422 line_limit_reached (details.maxLines: 50)
before your resolver is called. A line holds at most 10,000 units.
Verify the signature
Every request carries X-Throttle-Resolver-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>].
Each v1 is the lowercase hex HMAC-SHA256 of the signed
string with one signing secret:
throttle.line-resolver.v1.<t>.<raw body> -
The purpose label
throttle.line-resolver.v1makes a resolver signature and a webhook signature mutually unverifiable, although they use the same secret: a captured webhook can never be replayed at your resolver. - Sign over the raw body bytes as received. Parsing and re-serialising the JSON changes them.
- Rotation. Inside a rotation grace window Throttle sends two digests,
v1=<previous>,v1=<current>. Accept the request when any digest matches the secret you hold, so you keep working whether or not you have picked up the new secret yet. -
Refuse a timestamp more than 300 seconds from your clock, compare each
digest in constant time, and look at no more than four
v1values.
import { createHmac, timingSafeEqual } from 'node:crypto';
const PURPOSE = 'throttle.line-resolver.v1';
/**
* rawBody: the request body exactly as received, before JSON.parse.
* header: the X-Throttle-Resolver-Signature value.
* secret: your installation's webhook signing secret.
*/
export function verifyResolverSignature(rawBody: string, header: string, secret: string): boolean {
let t = NaN;
const digests: string[] = [];
for (const part of header.split(',')) {
const [key, value] = part.split('=', 2).map((s) => s.trim());
if (key === 't') t = /^\d+$/.test(value) ? Number(value) : NaN;
else if (key === 'v1' && digests.length < 4) digests.push(value);
}
if (!Number.isSafeInteger(t) || digests.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false; // replay window
const expected = createHmac('sha256', secret).update(`${PURPOSE}.${t}.${rawBody}`).digest();
// During a secret rotation the header carries one v1 per secret: accept any match.
return digests.some(
(d) => /^[0-9a-f]{64}$/i.test(d) && timingSafeEqual(expected, Buffer.from(d, 'hex')),
);
} Test vector
Your verifier must accept this request at t = 1760000000 (the secret is a published test value, not a real one):
body {"type":"cart.lines.resolve","id":"req_1"}
secret whsec_line_resolver_test_vector
t 1760000000
signed throttle.line-resolver.v1.1760000000.{"type":"cart.lines.resolve","id":"req_1"}
header t=1760000000,v1=12e7f5fb19c04b1230e5e80b31fbc0a2ebfd5fd53fa2a9ae87cdc4e72f8a905b The answer
Answer 2xx with a JSON body that has one entry per
requested line. A line is either priced (ok: true) or
refused (ok: false).
{
"lines": [
{
"referenceId": "TSHIRT-BLUE-M",
"ok": true,
"name": "Organic T-shirt, blue, M",
"unitPrice": 2500,
"currency": "USD",
"imageUrl": "https://cdn.example.com/tshirt-blue.jpg",
"minQuantity": null,
"maxQuantity": 10,
"requiresShipping": true,
"metadata": { "productId": "prod_123", "variantId": "var_456" }
},
{
"referenceId": "MUG-WHITE",
"ok": false,
"code": "QUANTITY_UNAVAILABLE",
"maxQuantity": 3
}
],
"holdExpiresAt": null
} | Field | Rule |
|---|---|
lines | Required. Exactly one entry per requested referenceId, matched as an exact string: a duplicate, an extra line, a missing line or a SKU that differs only in case fails the whole call. |
referenceId | The requested referenceId. |
name | Not blank. Shown to the buyer and copied to the order. |
unitPrice | Integer minor units (cents), 0 to 2,147,483,647. |
currency | Must equal the request currency, exactly. |
imageUrl | An https URL, or null. |
minQuantity / maxQuantity | Non-negative integers or null. Throttle stores them and shows them on the cart; refuse a quantity outside them with BELOW_MIN or ABOVE_MAX. |
requiresShipping | Boolean. Decides whether the line ships. Browser metadata never overrides it. |
metadata | An object, at most 2048 bytes of JSON. Stored on the line under metadata.lineResolver. Throttle sets its own minQuantity, maxQuantity and refusal keys there, over yours. |
code (refused) | One of the eight refusal codes below. |
maxQuantity (refused) | Optional on any refusal: how many the buyer can have. |
holdExpiresAt | Optional ISO 8601 date-time or null. Accepted and validated; this version does not use it. |
The body may be at most 256 KiB of UTF-8 JSON. Unknown keys are accepted and dropped, never stored.
Refusal codes and what the buyer gets
| Your code | Throttle answers | details |
|---|---|---|
OUT_OF_STOCK | 409 quantity_unavailable | { reason, maxQuantity?, referenceId, itemId? } |
QUANTITY_UNAVAILABLE | 409 quantity_unavailable | Same. With maxQuantity the message reads "Only 3 of this item can be ordered." |
BELOW_MIN | 409 quantity_unavailable | Same. |
ABOVE_MAX | 409 quantity_unavailable | Same. |
NOT_FOUND | 422 line_not_purchasable | { reason, referenceId, itemId? } |
NOT_PURCHASABLE | 422 line_not_purchasable | Same. |
NO_PRICE_IN_CURRENCY | 422 line_not_purchasable | Same. |
PRICE_SOURCE_MISMATCH | 422 line_not_purchasable | Same. |
reason is your code; maxQuantity
appears only when you sent it. On an add or a quantity change a refusal changes nothing: the
line keeps its old quantity. On a refresh a refused line keeps its quantity and price and is
flagged refused (its resolution.status reads refused). A merged quantity above 10,000 is refused by
Throttle itself as 409 quantity_unavailable with reason: "MAX_LINE_ITEM_QUANTITY" and maxQuantity: 10000.
Timeouts, retries and fail closed
- 3 seconds per attempt, counted from before the DNS lookup to the end of reading your body.
- One retry, for
checkoutonly, and only after a timeout, a network error or a5xx. The retry sends the same body with a fresh timestamp.add,updateandrefreshare never retried: a shopper is waiting. - Transport rules. HTTPS on port 443 to the declared host name. Redirects are never followed. The host must resolve to public addresses only: a name that resolves to a private, loopback or link-local address is never called. The check runs on every call, inside the 3-second budget.
503 line_resolver_unavailable. Nothing on the cart changes,
and the message says so. Throttle never stores part of an answer. The cause is not shown to
the buyer; details.reason appears only when the installation
itself is unavailable (see above).
What Throttle stores
From an ok answer, on the owned line:
-
name,unitPrice,imageUrland the quantity, always from the same answer; -
metadata.lineResolver: yourmetadataplusminQuantity,maxQuantityandrefusal; -
metadata.requiresShipping, which also sets the line's delivery method; - the owning installation and when the line was last resolved, on the cart line only. They are not copied to the order.
lineResolver and requiresShipping
are reserved line-metadata keys: a browser can never set them, on any line of a resolved
cart. Order lines copy the cart line's metadata, so metadata.lineResolver also appears on the order's lines
in order.created.
lineResolver object that looks like yours. That is every
cart session in an environment that leaves resolved lines only
off, which is the default. Orders and order.created carry no
Throttle-set marker of which installation priced a line. So before you act on an order line,
for example to decrement stock or to decide a price needs no report, check it against your
own catalog: look up its referenceId and compare its unitPrice and the order's currency with your price for that item. Do this for every
line, whatever its metadata says. To recognise your own answers, you can also put a value
only you can produce in your answer's metadata, such as
an HMAC over the installation id, referenceId, unitPrice and currency made
with your own key. Throttle keeps your keys under metadata.lineResolver; verify the value on order.created.
The cart view of a resolved cart adds two keys, and only on resolved carts: lineResolver: { extensionId } (null when the
installation can no longer be found) and, on each line, resolution: null on a line no resolver owns, otherwise { status, code, maxQuantity, minQuantity } with status resolved, refused (code says why) or stale (the last re-check failed; checkout re-checks it).
Sign-in merges
When a shopper signs in and claims a resolved cart (
POST /v1/storefront/me/carts/{cartId}/claim
), the resolved cart wins: the customer's other open cart is merged into it,
then every owned line is re-resolved (refresh) for the
signed-in customer. Owned lines never land in an unresolved cart.
-
Owned lines of the same resolver and
referenceIdadd up. - Lines no resolver owns move in without the reserved and quote-affecting metadata keys (listed on Cart sessions), and never add to an owned line.
- Other currency. If the customer's other cart is in another currency,
its owned lines are priced by their resolvers in this cart's currency first. Lines a
resolver refuses there are left behind and listed in
removedLines. If that cart holds any line no resolver owns, the merge is skipped.
// 200 from POST /v1/storefront/me/carts/{cartId}/claim on a resolved cart
{
"data": {
"cartId": "0f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a",
"customerId": "c3d4e5f6-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
"linesStale": false,
"removedLines": [{ "referenceId": "MUG-WHITE", "code": "NO_PRICE_IN_CURRENCY" }]
}
}
// When the merge is skipped instead: "mergeSkipped": { "reason": "line_limit_reached" },
// and no removedLines (nothing moved, so nothing was left behind). | Field | Meaning |
|---|---|
linesStale | The merge committed but a resolver did not answer the re-check: owned lines keep their prices, marked stale. Checkout re-checks them anyway. The claim still answers 200. |
mergeSkipped.reason | The merge did not happen; both carts keep their lines and the customer is still attached to this cart. One of quantity_unavailable (a summed line would pass 10,000), line_limit_reached (more than 50 owned lines for one resolver), amount_too_large (an amount would not fit), currency_mismatch (the other cart is in another currency and holds unowned lines) or line_resolver_unavailable (the other cart's owned lines could not be priced in this currency). |
removedLines | Owned lines of an other-currency cart that were not moved, each { referenceId, code } with your refusal code. |
Sign-in never fails because of a merge. A secret-key POST /api/v1/carts/{id}/merge into a resolved cart
follows the same rules and answers the cart with meta.warnings: merge_skipped (with reason,
one of the five above), lines_stale and lines_removed (with lines).
// 200 from POST /api/v1/carts/{id}/merge on a resolved cart (secret key)
{
"data": { "id": "0f8e7d6c-…", "lineItems": [ … ], "total": 7500 },
"meta": {
"requestId": "…",
"warnings": [
{ "code": "merge_skipped", "reason": "currency_mismatch", "message": "…" },
{ "code": "lines_stale", "message": "…" }
]
}
}
// A merge that did happen from an other-currency cart can instead carry
// { "code": "lines_removed", "lines": [{ "referenceId": "MUG-WHITE", "code": "NOT_FOUND" }], "message": "…" }.
// lines_removed never comes with merge_skipped. requiresShipping metadata
when it moves into a resolved cart, but keeps its delivery method. Throttle's shipping
quote can then treat a browser-priced digital line as shippable and charge shipping for it.
Price items that do not ship through your resolver with requiresShipping: false.
Checkout
The cart-session hand-off (POST /api/v1/cart-sessions/:id/checkout-session)
re-resolves every owned line with intent checkout, then
re-quotes the selected shipping method.
- Something changed (a price, a name, a refusal, or the shipping amount):
Throttle applies the new values and answers
409 cart_prices_changedwithdetails.cart(the updated cart) and, when shipping moved,details.shipping. The next hand-off goes through when nothing changes again. - A line is still refused:
422 line_not_purchasablewithdetails.lines, each{ itemId, referenceId, code, maxQuantity? }. The buyer removes the line or changes its quantity. - Your resolver does not answer (after the one retry):
503 line_resolver_unavailable; nothing changes.
// 409 from POST /api/v1/cart-sessions/{id}/checkout-session
{
"error": {
"code": "cart_prices_changed",
"message": "Prices in this cart changed. The cart now shows the new prices; review it and check out again.",
"details": {
"cart": { "cartId": "0f8e7d6c-…", "lineItems": [ … ], "total": 8200, "lineResolver": { "extensionId": "…" } },
"shipping": { "methodId": "standard", "previousAmount": 599, "amount": 799 }
}
}
} details.shipping is { methodId, previousAmount, amount, reason? }. amount is null with reason method_unavailable (the method is no longer offered and was
cleared) or quote_unavailable (it could not be quoted right
now and was kept). When only shipping cannot be quoted, the hand-off answers 503 shipping_quote_unavailable instead.
The completion guard
A checkout session minted by this hand-off can only be paid for what the hosted page last
priced. Throttle's hosted page (the checkoutUrl the
hand-off returns) runs a public POST /api/v1/checkout-sessions/:id/calculate with kind: "checkout_final", and /complete refuses with 409 cart_prices_changed unless the cart still matches that
calculation: the same lines, subtotal, discount and shipping, and the same shipping
destination (country, state, postal code, city and first address line). Nothing is charged:
a payment the embed already took is released (an authorisation voided, a capture refunded). Tax is re-quoted at completion as
for every checkout.
checkout_final calculation of their
own: the legacy /c/<id> page (the default route of <CheckoutEmbed> and <PaymentEmbed>) and its payment-only embed, Secure
Fields, and a merchant sk_ POST /api/v1/checkout/sessions/:id/complete. On a hand-off
session they answer 409 cart_prices_changed unless a public checkout_final calculation of the cart as it is now has
already run.
Server paths stay trusted and unchanged: a secret-key POST /api/v1/carts/:id/finalize (or /checkout) and POST /api/v1/checkout/sessions do not call your resolver;
they use the prices the cart holds.
Shipping on resolved carts
- Throttle quotes shipping itself. The browser names a method; Throttle
quotes the cart and stores its own amount. Use
cart.selectShippingMethod({ methodId })from@usethrottle/cart. A sentrateAmountorcurrencythat differs from the quote is400 price_locked; a sentdisplayName,carrier,serviceCodeorestimatedDeliveryDaysis replaced by the quote's. - No address, no method. A cart session cannot set an address, so before
checkout Throttle usually cannot quote:
422 shipping_method_unavailable. Shipping is normally chosen on the hosted checkout page, where the buyer enters the address. - No flat-rate fallback. A resolved cart with no selected method shows shipping as not calculated yet (0), never the unquoted flat rate.
- The hand-off re-quotes the selected method for the cart as it is then (see Checkout).
Error reference
Every error uses the standard envelope { error: { code, message, details? } }. A
503 always carries a real message, never "Internal server error". The typed classes
of @usethrottle/cart are listed on Cart sessions.
| Status | Code | details | When |
|---|---|---|---|
| 400 | price_locked | { fields } | A request sends a field only the resolver (or Throttle) may set: a price field, a reserved or quote-affecting metadata key, or a shipping amount that differs from the quote. |
| 409 | quantity_unavailable | { reason, maxQuantity?, referenceId, itemId? } | The resolver refused the quantity, or a line would pass 10,000. |
| 422 | line_not_purchasable | { reason, referenceId, itemId? } on an add or change; { lines } at checkout | The resolver refused the item. |
| 503 | line_resolver_unavailable | { reason? } | The resolver did not answer, or the installation of an existing cart is unavailable. Nothing changed; retry. |
| 422 | line_resolver_not_available | { reason } | No usable installation for lineResolver at cart creation. |
| 422 | currency_not_enabled | { currency, enabledCurrencies } | The resolved cart's currency is not the environment's. |
| 422 | resolved_lines_required | — | The environment only accepts resolved lines in browser carts, and this cart session is not resolved. |
| 422 | line_limit_reached | { maxLines } | A 51st owned SKU for one resolver. |
| 409 | cart_prices_changed | { cart, shipping? } at the hand-off; none from /complete | Prices or shipping changed at checkout, or a guarded checkout session no longer matches its last calculation. |
| 409 | cart_changed | — | Another add changed the same line while the resolver answered, twice in a row. Nothing was added; retry. |
| 422 | amount_too_large | — | A line or cart amount would not fit (above 2,147,483,647 minor units). Lower the quantity. |
| 422 | shipping_method_unavailable | — | Throttle does not quote that method for this cart (often: no address yet). |
| 503 | shipping_quote_unavailable | — | Shipping or tax cannot be quoted right now. Nothing changed; retry. |
| 409 | cart_not_open | — | The cart is abandoned, in checkout or converted (and, for a hosted-cart session mint, expired). |
| 422 | line_resolver_requires_webhook | — | Registering lineResolverUrl without webhookUrl and an event subscription. |
| 422 | duplicate_line_item | — | Internal guard. Throttle never sends it on purpose; if you see it, report it as a bug. |
| 422 | invalid_line_resolution | — | Internal guard that should never reach a client; if you see it, report it as a bug. |