The constraint that decides the architecture
So whatever price the function uses has to already be in the cart line before it runs. It was put there earlier, by something else, somewhere the customer could reach. That single fact decides the architecture of every store that sells a product priced by what the customer types, and it is the part the existing guidance skips.
Before any of this: whether your product should be priced this way at all is a separate question, and we argued it elsewhere. This piece assumes you have answered it and the answer was yes.
Why bundle guidance doesn't transfer
Cart Transform was built for bundles, and most of what is written about it is about bundles. That writing is correct.
It does not transfer, because a bundle price is known before the customer arrives. The merchant decided that three items sold together cost forty pounds; the function's job is to present a number the merchant already owns. Nothing has to be worked out at request time.
A computed price is not known in advance. It does not exist until someone enters a width, a depth and a material. There is no server-side record of it waiting to be displayed, because until that moment there was nothing to record. Everything hard about this topic lives in that gap, and a bundle never has to cross it.
The number the browser sent you
If the price has to be in the cart line before the function runs, the obvious move is to put it there as a line item property. This is where most builds go wrong.
Line item properties are set from client-side JavaScript. The theme posts to /cart/add.js with a properties object, and that object is assembled in the browser. They can also be rewritten afterwards: any POST to /cart/change.js that includes properties overwrites the entire properties object.
The usual defence is the underscore prefix. Shopify's documentation says that to make a line item property private, you prepend an underscore to the key — and a great many builds read "private" as "the customer cannot touch this". It means the property is hidden from the rendered cart. It is a display convention. The same endpoint the theme posts to will accept a different value for that key, from anyone, at any point before checkout.
What happens next is the problem. A store that reads that property and prices the line from it has no way to tell a computed price from a typed one. There is no error, because nothing invalid happened — a well-formed request set a property, and the store did exactly what it was built to do. The payment is taken. The order is recorded. Everything downstream treats it as correct, because by then it is the only version of events that exists.
A price arriving from the browser is not an input to be trusted. It is a claim to be checked.
The routes that actually exist
Before comparing them, one rule decides which are even available. Stores on any plan can use public apps distributed through the App Store that contain Functions — but only Plus stores can use custom apps containing them. For a bespoke build on a non-Plus store, Functions are not on the table at all unless you publish a public app.
lineUpdate overrides the price, title and image of a line. It is the cleanest fit and it is restricted to Plus and development stores. Two things to know before committing: Shopify rejects it outright when a selling plan is present on the line, and whether it can set a price above the variant's own price is not something we could confirm from the documentation. The field description mentions "additional charges", which suggests it can. Test it on a development store before a build depends on it.
lineExpand is reachable on any plan, through a public app. For one merchant that means publishing and maintaining an App Store app — review, versioning, a support surface — to serve a single store. Occasionally that is the right trade. Usually the merchant wanted a configurator, not a product.
Draft orders give you real authority. A custom line item takes an originalUnitPrice with no variant behind it, so the server sets the price outright and nothing in the browser participates. The cost is that the customer leaves the storefront checkout: the order is created first and paid through an invoice, which is a different purchase experience and worth being honest with a client about before choosing it.
Dynamically created variants put a real price on a real variant, which every downstream system already understands — reporting, inventory, exports, the ERP. You pay in catalogue pollution. Variants accumulate per order, and the cleanup is a job someone owns forever.
Quantity as price is still out there: price the variant at one penny, set the quantity to the computed total. It survives because it needs no app, no plan and no approval. It also destroys the order record. Quantity stops meaning how many things were bought, which breaks inventory, per-unit reporting and every downstream consumer that reasonably assumed otherwise.
Where authority should sit
Compute the price on your server. That part is not controversial.
The part that is: do not put the resulting price in the cart. Put something that cannot be usefully altered — either a signed quote, or a reference to a quote your server has stored and can look up. The customer can still change it. They just cannot change it into something that verifies.
Then check it at the order boundary, which is the last point where a wrong price is cheap. Signing alone is not enough, and it is worth being precise about why. A signature proves the quote came from you. It does not prove your pricing rules are the same as when you issued it, and it does not prove the line that was signed is the line that was charged. Recomputing the price from the specification at the order boundary establishes both, and it costs one function call against an order you are already processing.
That is a shape, not a blessed pattern. Where the check lives — an order webhook, a reconciliation job, the fulfilment step — depends on what your business does when it finds a mismatch, and that is a commercial decision before it is a technical one.
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Canonical form of a spec, so the same configuration always signs identically.
* Key order in an object literal is not guaranteed across the code paths that
* build it, and JSON.stringify preserves insertion order — sort explicitly.
*/
function canonical(spec) {
return JSON.stringify(
Object.keys(spec)
.sort()
.map((k) => [k, spec[k]]),
);
}
/**
* Prices a configuration server-side and signs the result.
*
* The cart carries the spec and this signature. It never carries a bare price,
* because a line item property is a value the browser sent and can send again.
*/
export function quote(spec, priceOf, secret) {
const amount = priceOf(spec);
if (!Number.isInteger(amount) || amount < 0) {
throw new Error(`priceOf must return a non-negative integer of minor units, got ${amount}`);
}
const payload = `${canonical(spec)}|${amount}`;
const signature = createHmac("sha256", secret).update(payload).digest("hex");
return { spec, amount, signature };
}
/** Constant-time compare that tolerates unequal lengths without throwing. */
function safeEqual(a, b) {
const bufA = Buffer.from(String(a), "utf8");
const bufB = Buffer.from(String(b), "utf8");
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
/**
* Re-derives the signature and compares. Returns a reason rather than a bare
* false, because "no signature" and "wrong amount" want different handling.
*/
export function verifyQuote({ spec, amount, signature }, secret) {
if (!spec || typeof spec !== "object") return { ok: false, reason: "missing-spec" };
if (!Number.isInteger(amount)) return { ok: false, reason: "missing-amount" };
if (typeof signature !== "string" || signature.length === 0) {
return { ok: false, reason: "missing-signature" };
}
const expected = createHmac("sha256", secret)
.update(`${canonical(spec)}|${amount}`)
.digest("hex");
return safeEqual(expected, signature) ? { ok: true } : { ok: false, reason: "bad-signature" };
}
/**
* The check that actually matters: recompute the price from the spec at the
* order boundary and compare it with what the customer was charged.
*
* A signature proves the quote came from you. It does not prove your pricing
* rules have not changed since, and it does not prove the signed line is the
* line that was charged. This does both, and it is the last point at which
* a wrong price is still cheap to find.
*/
export function auditOrderLine(line, priceOf, secret) {
const { spec, amount, signature, chargedAmount } = line;
const verified = verifyQuote({ spec, amount, signature }, secret);
if (!verified.ok) return { ok: false, reason: verified.reason, line };
if (chargedAmount !== amount) {
return { ok: false, reason: "charged-differs-from-quote", expected: amount, got: chargedAmount };
}
const recomputed = priceOf(spec);
if (recomputed !== amount) {
// Not necessarily fraud — a pricing change between quote and order looks
// identical here. Both need a human, which is the point.
return { ok: false, reason: "recompute-differs-from-quote", expected: recomputed, got: amount };
}
return { ok: true };
}What it costs to get wrong
Wrong prices are quiet. Nothing throws, no log line appears, no alert fires, and the order looks exactly like a correct one. They are found by reconciliation — someone comparing what was charged against what should have been, usually weeks later, usually because a margin looked odd.
There is a related gap worth knowing. Validation functions have a documented setting that decides whether a runtime failure blocks checkout. Cart Transform has no documented equivalent, and the documentation does not say what happens to a cart whose transform failed — whether the untransformed price simply proceeds. If your correct price depends on a function running, that is worth establishing yourself rather than assuming.
And it is expensive to revisit. The decision is embedded in the cart, the checkout, the order record and everything reading orders afterwards. Across four configurator builds the pattern held: the pricing model was settled early and everything downstream was shaped by it. On Custom Floating Shelves, pricing across eighteen materials with fractional dimensions and material-specific restrictions had to survive the trip into the cart intact. On Online Stone Solutions, where shipping rather than product price was the calculated value, the same conclusion arrived independently: storefront JavaScript is not an appropriate source of truth.
When this needs an engineer
The test is where the number comes from. If the price is a lookup — a variant, a price list, a tier — Shopify already holds it and an app will move it around for you.
If the price is a function of what the customer entered, then it does not exist until they enter it, and someone has to decide where the authoritative version lives and be able to defend that choice against a request that did not come from your storefront. That decision is made once, early, and everything else is built on top of it. It is worth building deliberately rather than discovering it in a reconciliation.
Send us the store and the symptom.
