Extensions

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 lineResolver is 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
An add on a resolved cart: Throttle prices the line through your resolver.

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.

Register an extension with a resolver
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 }'
RuleWhat 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 iframeUrl and webhookUrl (domain_not_verified otherwise).
  • A new domain forces review. A version whose resolver domain differs from the approved one is flagged changed_line_resolver_domain and 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.
Older managed endpoints fail closed
Throttle reads the signing secret from the managed webhook endpoint linked to the installation. An older installation whose managed endpoint does not carry that link is treated as having no secret: creating a resolved cart answers 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:

reasonMeaning
not_installedThe extension has no installation in this application and environment.
installation_inactiveThe installation exists but is not active (for example, suspended).
no_resolver_urlThe pinned version declares no lineResolverUrl.
no_signing_secretThe installation has no active managed webhook endpoint linked to it.
external_cart_providerThe application uses an external cart provider. Resolved carts are native carts only.
not_configuredResolved 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.

Resolver request
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 }
  ]
}
FieldMeaning
typeAlways cart.lines.resolve.
idA UUID for this request. The checkout retry sends the same body, so the same id.
installationIdYour installation. Use it to find the merchant catalog to price from.
applicationId / environmentIdThe cart's application and workspace environment.
cartIdThe Throttle cart. On a create it is the id the new cart will get.
intentWhy Throttle is asking: add, update, refresh or checkout (below).
currencyThe cart currency. Every ok line must answer in it.
customerIdThe signed-in customer of the cart, or null for a guest cart.
customerEmailVerifiedTrue when that customer has verified the email on their storefront account.
localeThe buyer locale given at cart creation (for example en-GB), or null.
lines1 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.
intentSent whenLines
addA 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.
updateThe quantity of an owned line changes.That one line.
refreshAn 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.
checkoutThe 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:

Signed string
throttle.line-resolver.v1.<t>.<raw body>
  • The purpose label throttle.line-resolver.v1 makes 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 v1 values.
Verify a resolver request (Node.js)
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):

Published test vector
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).

Resolver answer
{
  "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
}
FieldRule
linesRequired. 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.
referenceIdThe requested referenceId.
nameNot blank. Shown to the buyer and copied to the order.
unitPriceInteger minor units (cents), 0 to 2,147,483,647.
currencyMust equal the request currency, exactly.
imageUrlAn https URL, or null.
minQuantity / maxQuantityNon-negative integers or null. Throttle stores them and shows them on the cart; refuse a quantity outside them with BELOW_MIN or ABOVE_MAX.
requiresShippingBoolean. Decides whether the line ships. Browser metadata never overrides it.
metadataAn 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.
holdExpiresAtOptional 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 codeThrottle answersdetails
OUT_OF_STOCK409 quantity_unavailable{ reason, maxQuantity?, referenceId, itemId? }
QUANTITY_UNAVAILABLE409 quantity_unavailableSame. With maxQuantity the message reads "Only 3 of this item can be ordered."
BELOW_MIN409 quantity_unavailableSame.
ABOVE_MAX409 quantity_unavailableSame.
NOT_FOUND422 line_not_purchasable{ reason, referenceId, itemId? }
NOT_PURCHASABLE422 line_not_purchasableSame.
NO_PRICE_IN_CURRENCY422 line_not_purchasableSame.
PRICE_SOURCE_MISMATCH422 line_not_purchasableSame.

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 checkout only, and only after a timeout, a network error or a 5xx. The retry sends the same body with a fresh timestamp. add, update and refresh are 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.
Fail closed
A timeout, a network error, a non-2xx status, a redirect, a body over 256 KiB, a body that is not JSON, a missing, extra or duplicate line, an ok line in another currency, or any line that breaks the rules above refuses the operation with 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, imageUrl and the quantity, always from the same answer;
  • metadata.lineResolver: your metadata plus minQuantity, maxQuantity and refusal;
  • 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.

metadata.lineResolver is a hint, not proof
On a cart that is not resolved, a browser can put any metadata on a line, including a 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 referenceId add 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.
Claim answer on a resolved cart
// 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).
FieldMeaning
linesStaleThe 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.reasonThe 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).
removedLinesOwned 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).

Secret-key merge warnings
// 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.
Unowned lines moved into a resolved cart can be shipped
A line no resolver owns loses its 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_changed with details.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_purchasable with details.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 cart_prices_changed
// 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.

Pay on the returned checkoutUrl
These surfaces run no 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 sent rateAmount or currency that differs from the quote is 400 price_locked; a sent displayName, carrier, serviceCode or estimatedDeliveryDays is 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.

StatusCodedetailsWhen
400price_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.
409quantity_unavailable{ reason, maxQuantity?, referenceId, itemId? }The resolver refused the quantity, or a line would pass 10,000.
422line_not_purchasable{ reason, referenceId, itemId? } on an add or change; { lines } at checkoutThe resolver refused the item.
503line_resolver_unavailable{ reason? }The resolver did not answer, or the installation of an existing cart is unavailable. Nothing changed; retry.
422line_resolver_not_available{ reason }No usable installation for lineResolver at cart creation.
422currency_not_enabled{ currency, enabledCurrencies }The resolved cart's currency is not the environment's.
422resolved_lines_required—The environment only accepts resolved lines in browser carts, and this cart session is not resolved.
422line_limit_reached{ maxLines }A 51st owned SKU for one resolver.
409cart_prices_changed{ cart, shipping? } at the hand-off; none from /completePrices or shipping changed at checkout, or a guarded checkout session no longer matches its last calculation.
409cart_changed—Another add changed the same line while the resolver answered, twice in a row. Nothing was added; retry.
422amount_too_large—A line or cart amount would not fit (above 2,147,483,647 minor units). Lower the quantity.
422shipping_method_unavailable—Throttle does not quote that method for this cart (often: no address yet).
503shipping_quote_unavailable—Shipping or tax cannot be quoted right now. Nothing changed; retry.
409cart_not_open—The cart is abandoned, in checkout or converted (and, for a hosted-cart session mint, expired).
422line_resolver_requires_webhook—Registering lineResolverUrl without webhookUrl and an event subscription.
422duplicate_line_item—Internal guard. Throttle never sends it on purpose; if you see it, report it as a bug.
422invalid_line_resolution—Internal guard that should never reach a client; if you see it, report it as a bug.

Related