Never trust the browser: re-checking prices on the server in a Shopify form

If a form's price is worked out in the browser, the browser can change it. The pattern I use: show an estimate, then work the real price out again on the server.

Instant quote forms are a good way to turn a visitor into a lead. Pick what you have, answer a couple of questions about its condition, and a price appears straight away. They’re also an easy way to give money away, because the price is worked out in the browser. Anything that runs in the browser can be changed by whoever is sitting at it.

This post covers the pattern I use for any form where the number on screen ends up meaning money: the browser shows an estimate, and the server works out the real figure again from scratch.

The problem with trusting the form

Say a form works out a price in JavaScript and submits it:

{
  "item": "laptop-14-inch",
  "condition": "good",
  "price": 420
}

Anyone with the browser’s dev tools open can change 420 to 4200 before it’s sent. They don’t even need the form: they can replay the request with curl. If the back end stores whatever price it receives, you’ve built a “name your own price” feature without meaning to.

It doesn’t take a malicious visitor, either. A stale tab holding yesterday’s prices, a cached script, or a rounding difference between two bits of code can all send a figure that isn’t the one you’d quote today.

The rule: the client’s number is advisory

The fix is simple to state. The browser sends the inputs, and the server works out the price from those inputs and its own price data. If the browser sends a price too, it’s only used for comparison, never stored as the quote.

In practice the submit handler looks something like this:

export async function handleSubmit(request, env) {
  const payload = await request.json();

  // Inputs only. The client's price is read, but only to compare.
  const inputs = validateInputs(payload);
  const config = await loadPricingConfig(env); // the shop's own prices

  const price = calculatePrice(inputs, config);

  if (payload.price !== undefined && payload.price !== price) {
    // Worth knowing about, but never worth acting on.
    console.warn('Price mismatch', { client: payload.price, server: price });
  }

  return saveQuote({ ...inputs, price });
}

Two details make this work.

The server derives everything that affects the price. If the price depends on a condition grade, don’t let the browser send "grade": "A". Send the answers to the condition questions, such as screen damage and whether the battery holds charge, and let the server work out the grade. A grade chosen in the browser is just a price chosen in the browser with an extra step.

The browser and the server use the same price data. The estimate should match the final figure in nearly every case, or customers see one number and get emailed another. I give the front end a read-only endpoint that returns ready-calculated prices, so the browser does no arithmetic of its own. It just looks figures up. All the maths lives in one place, on the server.

Doing this on Shopify

A Shopify theme can’t run server code, so the recalculation has to happen somewhere else. A Shopify App Proxy works well for this. It forwards requests made to a path on the shop’s own domain, such as /apps/quotes/submit, to a server you control, and signs each request so you can tell it really came through Shopify. I’ll cover building one on Cloudflare Workers in a later post.

The prices themselves can live in shop metafields, so the client can adjust them in the Shopify admin. The server reads them once per request and caches them briefly, rather than calling the Admin API for every price it works out.

Integer arithmetic, not floats

Once the server owns the maths, make sure the maths is exact. Money in JavaScript floats will eventually bite you:

Math.round(50 * 1.15);       // 57.49999999999999 rounds DOWN to 57
Math.round((50 * 115) / 100); // 57.5 rounds UP to 58

Multiplying by 1.15 looks right and is wrong for some inputs, because 1.15 can’t be represented exactly in binary. Multiply by the whole-number percentage first, then divide. Better still, work in pence throughout and only format as pounds for display.

Floors and minimums belong on the server too

Business rules that protect the margin, such as a minimum quote or a fee that must always be more than zero, need to be in the server’s calculation. If they’re only enforced in the form, they can be skipped by sending the request directly.

It’s also worth deciding what happens at the edges rather than letting the formula decide. A tiny item can work out at a few pence, and walking someone through a five-step form to offer them 88p is a poor experience. A minimum, with a friendly route to talk to a person instead, is better than a price that’s technically correct.

Test the server’s numbers, not the form’s

The calculation is now a pure function: inputs and price data in, a number out. That makes it the easiest part of the system to test, and the most important. A table of known inputs and expected prices, run on every push, catches the “someone changed a multiplier and now everything is 10% out” mistake before a customer does.

test.each([
  ['laptop-14-inch', 'good', 42000],
  ['laptop-14-inch', 'fair', 38500],
  ['tiny-item', 'good', null], // under the minimum: no quote
])('%s in %s condition', (item, condition, expected) => {
  expect(calculatePrice({ item, condition }, fixtureConfig)).toBe(expected);
});

Summary

  • The browser shows an estimate; the server works out the price that counts.
  • Send inputs, not results. Derive grades and categories on the server.
  • Keep one source of price data and have the front end look figures up rather than calculate them.
  • Use integer arithmetic for money.
  • Put minimums and floors in the server-side calculation, and test it.

If you have a quote or trade-in form that needs this kind of back end, it’s the sort of Shopify custom app work I do.

Written by

James Knowles

Started in 2021 as an apprentice on a support desk, answering clients directly, then worked through agency teams on WordPress, WooCommerce and Shopify builds. Now leads Ramosa and every project.

More from James

Planning a new shop?