# Build a Gold Jewelry Price Calculator with JavaScript

Jewelers, pawn shops, and ecommerce sellers all need the same small tool: type in a weight and a karat, get back a material value. This post builds that tool end to end, from the live per-gram price to a browser UI, using plain JavaScript.

## What this calculator estimates, and what it doesn't

The output is a **material value**: what the gold content of a piece is worth at the current spot price, before anything else gets added. It is not an appraisal, not a guaranteed resale value, and not what a buyer will actually pay.

Left out entirely:

- Gemstones, enamel, or any non-gold material in the piece
- Workmanship and design (a hand-finished ring costs more to make than a stamped one)
- Taxes and duties, which vary by country and by transaction type
- Dealer spreads: the gap between what a shop pays you and what spot says
- Condition: scratches, missing stones, worn engraving
- Buyback discounts a shop applies to protect its margin

Every number this calculator produces should carry a line telling the user that. Build the disclaimer into the UI, not just the docs.

## Step 1: Fetch the eight per-gram prices

The API's `/v1/carat` endpoint returns per-gram prices for eight common karat purities in one call, no API key required:

```
GET https://api.goldprice.dev/v1/carat?currency=USD
```

A real response looks like this:

```json
{
  "currency": "USD",
  "timestamp": "2026-07-16T18:12:06.394451Z",
  "price_gram_24k": "128.24",
  "price_gram_22k": "117.55",
  "price_gram_21k": "112.21",
  "price_gram_20k": "106.86",
  "price_gram_18k": "96.18",
  "price_gram_16k": "85.49",
  "price_gram_14k": "74.80",
  "price_gram_10k": "53.43"
}
```

Two details matter for the code below. Every price field is a **string**, not a number, so the exact decimal digits survive the JSON round trip. And `currency` accepts any of the 31 currencies the API supports, so the same endpoint covers USD, EUR, INR, or IDR by changing one query parameter.

## Step 2: Cache it server-side, 60 seconds

The upstream data itself updates on a similar cadence, so polling it on every page load buys nothing and adds latency. A small Node server with an in-memory cache sits in front:

```js
import { createServer } from "node:http";

const UPSTREAM = "https://api.goldprice.dev/v1/carat";
const TTL_MS = 60_000;

const cache = new Map(); // currency -> { data, fetchedAt }

async function fetchCarat(currency) {
  const res = await fetch(`${UPSTREAM}?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`upstream ${res.status}`);
  }
  const data = await res.json();
  if (typeof data.price_gram_24k !== "string") {
    throw new Error("malformed upstream response");
  }
  return data;
}

async function getCarat(currency) {
  const cached = cache.get(currency);
  const now = Date.now();

  if (cached && now - cached.fetchedAt < TTL_MS) {
    return cached.data;
  }

  try {
    const data = await fetchCarat(currency);
    cache.set(currency, { data, fetchedAt: now });
    return data;
  } catch (err) {
    if (cached) {
      return cached.data; // stale-if-error: serve last known value
    }
    throw err;
  }
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname !== "/api/carat") {
    res.writeHead(404).end("not found");
    return;
  }
  const currency = (url.searchParams.get("currency") || "USD").toUpperCase();

  try {
    const data = await getCarat(currency);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(data));
  } catch (err) {
    res.writeHead(502, { "content-type": "application/json" }).end(
      JSON.stringify({ error: "carat_unavailable" })
    );
  }
});

server.listen(3001);
```

This is plain `node:http`, no framework needed for a single endpoint. The failure handling matters as much as the happy path: a non-200 upstream response or a malformed body throws, and `getCarat` falls back to the last cached value if one exists and is still within the TTL window. Only when there is no cache at all does the error reach the client, as a 502.

Do not call `api.goldprice.dev` directly from browser JavaScript. The API deliberately has no browser CORS surface, by design, so a `fetch` from client-side code will fail. Route every request through your own server endpoint, which also keeps the cache shared across all visitors instead of one per browser tab.

## Step 3: Material value: weight times per-gram price

The core formula is one multiplication: `weight_grams × per_gram_karat_price`. The catch is that both operands need to stay in exact decimal arithmetic, because floating-point multiplication of money values drifts:

```js
console.log(0.1 + 0.2); // 0.30000000000000004
```

The API already gives you exact decimal strings. The fix is to keep them as integers in minor units (cents) instead of converting to `Number` and back:

```js
function toMinorUnits(decimalString, decimals = 2) {
  const [whole, frac = ""] = decimalString.split(".");
  const paddedFrac = (frac + "0".repeat(decimals)).slice(0, decimals);
  const sign = whole.startsWith("-") ? -1n : 1n;
  const wholeAbs = whole.replace("-", "");
  return sign * (BigInt(wholeAbs) * 10n ** BigInt(decimals) + BigInt(paddedFrac || "0"));
}

function fromMinorUnits(minorUnits, decimals = 2) {
  const negative = minorUnits < 0n;
  const abs = negative ? -minorUnits : minorUnits;
  const divisor = 10n ** BigInt(decimals);
  const whole = abs / divisor;
  const frac = (abs % divisor).toString().padStart(decimals, "0");
  return `${negative ? "-" : ""}${whole}.${frac}`;
}

function materialValue(weightGrams, perGramPriceString) {
  const priceMinor = toMinorUnits(perGramPriceString, 2); // cents
  const weightMilligrams = BigInt(Math.round(weightGrams * 1000)); // 3dp precision
  const totalMinor = (priceMinor * weightMilligrams) / 1000n;
  return fromMinorUnits(totalMinor, 2);
}
```

No big-number dependency here, just `BigInt`, which every current JS runtime ships natively. Weight comes in as a plain float (a jeweler's scale reads to 0.01g or 0.001g), so it gets converted to integer milligrams before the multiplication, and the division back down happens last, after the multiply, so intermediate values stay exact. Note that integer division truncates rather than rounds; pick an explicit rounding rule for your currency's minor unit before this ships to production, the same way the API itself rounds each karat price to the currency's own decimal step.

## Step 4: Add margin and making charges separately

The material value from step 3 is a spot-derived number. It is not what a shop should quote a customer. Two more inputs belong on top of it, and they belong as separate line items, never folded into the spot number itself:

- Merchant margin: the shop's markup or buy-side discount, usually a percentage
- Making charges: the cost of turning raw gold into a finished piece, usually a flat fee or a percentage of material value, and entirely independent of the spot price

```js
function quoteBreakdown(materialValueMinor, marginPercent, makingChargeMinor) {
  const marginMinor = (materialValueMinor * BigInt(Math.round(marginPercent * 100))) / 10000n;
  return {
    material: materialValueMinor,
    margin: marginMinor,
    makingCharge: makingChargeMinor,
    total: materialValueMinor + marginMinor + makingChargeMinor,
  };
}
```

Keeping these as three fields instead of one blended total is what makes the calculator auditable. A customer, or a regulator, should be able to see the spot-derived material value on its own line, separate from what the shop is choosing to add.

## Step 5: Display timestamp, currency, and the limits

The browser side calls your server, never the upstream API:

```js
async function loadCarat(currency) {
  const res = await fetch(`/api/carat?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`carat request failed: ${res.status}`);
  }
  return res.json();
}

function estimateValue(weightGrams, karat) {
  const karatKey = `price_gram_${karat}k`;

  return loadCarat("USD").then((data) => {
    const perGram = data[karatKey];
    if (!perGram) {
      throw new Error(`unsupported karat: ${karat}`);
    }
    return {
      material: materialValue(weightGrams, perGram),
      timestamp: data.timestamp,
      currency: data.currency,
    };
  });
}

document.getElementById("calc-btn").addEventListener("click", () => {
  const grams = Number(document.getElementById("weight").value);
  const karat = Number(document.getElementById("karat").value);

  estimateValue(grams, karat)
    .then((result) => {
      document.getElementById("result").textContent =
        `${result.material} ${result.currency} as of ${result.timestamp}`;
    })
    .catch((err) => {
      document.getElementById("result").textContent =
        "Price unavailable right now. Try again shortly.";
      console.error(err);
    });
});
```

Every result the UI shows needs the timestamp and currency next to it, and a fixed line of disclaimer text below it: this is a material-value estimate, not an appraisal, and it excludes stones, workmanship, taxes, and condition. Put that sentence in the template once, not in a tooltip someone can miss.

## Step 6: Handle fetch failure and malformed data

Failure has three shapes at the server boundary, and the code above already handles all three:

- The upstream request itself fails (network error, timeout): caught in `fetchCarat`, falls back to cache
- The response is a non-200 status: checked explicitly, treated the same as a network failure
- The response body doesn't look right (missing fields, wrong types): the `typeof data.price_gram_24k !== "string"` check catches a malformed body before it gets cached or served

When a fresh cache entry exists, none of this reaches the user; they get the last good value with its real timestamp. When there is no usable cache, the client gets a clear error state instead of a broken calculation or a silent zero.

## Licensing

The anonymous endpoint used throughout this post is fine for development and testing. Commercial use of the raw API data (in a product you charge for, or that drives revenue) requires Pro plan licensing or higher. If all you need is a live price displayed on a page, not raw data to compute against, the [official embeddable widget](https://goldprice.dev/data/gold/embed) is free for commercial display use and needs no API key.

## Related reading

- [API reference: /v1/carat](/docs/api-reference#carat)
- [Interactive currency converter](/convert)
- [Pricing and licensing tiers](/pricing)
- [Troy ounce, gram, kilo: gold weight units explained](/blog/gold-weight-units)
- [What you actually pay for physical gold: dealer premiums by country](/blog/physical-gold-premium-by-country)
