Build a Jewelry Price Calculator with Live Gold Prices
Build a server-side jewelry price calculator in TypeScript and Python with live gold prices per gram, karat purity, making charges, VAT and quota-safe caching.
What the calculator does
Jewelry is usually priced from four inputs: the weight of the piece, the purity of its gold, the current gold price and the jeweler's labour, with tax on top. A calculator that does this for every product page or quote request needs a gold price per gram in the shop's currency. It also needs that price fast, within the API quota, and without exposing the API key to the browser.
This tutorial builds a small server-side service in TypeScript (Node.js) and in Python. The browser, or your shop backend, sends a weight and a karat to your own endpoint. Your server combines them with a cached gold price from MetalAPI and returns a full price breakdown. The formula:
metal value = weight (g) × gold price per gram (24K) × purity
making charge = per gram, a percentage of the metal value, or a flat fee
net price = metal value + making charge
VAT = net price × VAT rate
total = net price + VATSpot gold is a wholesale reference price. Retail prices also reflect design, stones, wastage and margin, and buyback prices are usually below spot. Treat this calculator as the metal-based part of your pricing and add your own business rules on top.
Gold per gram in your currency
The /latest endpoint returns prices in any base currency and, for metals, in any weight unit. One request gives you the price of one gram of fine gold in euros:
curl "https://metalapi.com/api/v1/latest?base=EUR¤cies=XAU&unit=gram" \
-H "X-API-Key: YOUR_API_KEY"{
"success": true,
"timestamp": 1792834892,
"date": "2026-10-24",
"base": "EUR",
"unit": "gram",
"rates": {
"XAU": 0.0090909091,
"EURXAU": 110
},
"rate_limit": { "limit": 10000, "remaining": 9874, "...": "..." }
}rates.EURXAU is the price of one unit of XAU, here one gram, in the base currency. That is the number the calculator needs. rates.XAU is the inverse, grams of gold per euro. The response includes unit whenever it is not the troy ounce default, and timestamp is the time of the price data. Show it next to every quote.
Base currency and unit work on every plan, Free included, and the same request can include XAG, XPT and XPD if you also sell silver or platinum pieces. Paid plans can also use the carat endpoint, which returns karat prices such as XAU22K directly, or every karat at once with carat=all. This tutorial applies purity itself so that the code runs on any plan and can use hallmark fineness values, which is the next topic.
Purity, making charges and VAT
Karat expresses gold content in 24ths, so the purity factor is karat / 24, and that is the convention the /carat endpoint uses. Many markets hallmark gold by fineness in parts per thousand instead, and for some karats the two differ slightly:
| Karat | karat / 24 | Common hallmark |
|---|---|---|
| 24K | 1.0000 | 999 |
| 22K | 0.9167 | 916 |
| 21K | 0.8750 | 875 |
| 18K | 0.7500 | 750 |
| 14K | 0.5833 | 585 |
| 9K | 0.3750 | 375 |
For a 5.2 g 14K ring at an illustrative €110 per gram, 14/24 gives a metal value of €333.67 and 0.585 gives €334.62. Neither is wrong. Use the convention your hallmarks and invoices use, which is why the code below takes a purity factor rather than a karat. The karat price guide covers the background in more depth.
Making charges usually take one of three forms, and the TypeScript version supports all of them:
- Per gram: a fixed amount for each gram of the piece, for example €12/g.
- Percentage: a share of the metal value, for example 8%. It rises and falls with the gold price.
- Flat: a fixed fee per piece, common for small items and custom work.
VAT or sales tax comes last. The rate, and what it applies to, depends on the country: some tax the full price, some treat investment gold differently from jewelry, and some tax only the labour. Keep the rate and the taxable amount in configuration, confirm them with your accountant, and round each line to cents so that the lines on an invoice add up to its total.
Caching to stay within your quota
Each plan updates prices at a fixed interval, and the updates fall on multiples of that interval counted from 00:00 UTC: at :00, :10, :20 and so on for the Standard plan, and once a day at midnight UTC for Free. Every request between two of these boundaries returns the same prices, so the ideal cache refreshes once per boundary. Refreshing at every boundary, around the clock, costs this much in a 31-day month:
| Plan (monthly quota) | Update interval | Requests at every boundary | Within quota? |
|---|---|---|---|
| Free (100) | Daily | 31 | Yes |
| Lite (1,000) | 30 min | 1,488 | No: refresh hourly instead (744) |
| Standard (10,000) | 10 min | 4,464 | Yes |
| Advanced (50,000) | 1 min | 44,640 | Yes, with less room for other calls |
Two more rules make the cache robust. Use single flight: when the cache expires under load, the first request fetches a new price and concurrent requests wait for that result instead of each calling the API. And serve stale on error: if the API cannot be reached, keep quoting the last good price for a few hours and retry once a minute, instead of failing every checkout. Requests that fail with a server error are not billed, but there is still no reason to send a burst of them during an outage.
The cache below lives in process memory, which is ideal for a single server. On serverless platforms or with several instances, each instance keeps its own copy. Put the price in a shared store such as Redis, or use your framework's data cache with the same expiry.
The TypeScript implementation
The module below runs on Node.js 18 or newer and has no dependencies. It fetches the euro price per gram, caches it until the next plan boundary, and turns a weight, a purity factor and a making charge into a priced breakdown:
// gold-quote.ts — Node 18+. The API key stays on the server.
const API_KEY = process.env.METALAPI_KEY!;
const CURRENCY = "EUR";
const PLAN_INTERVAL_S = 600; // Standard plan: prices update every 10 minutes
const MIN_TTL_S = 0; // Lite plan: set 3600 to stay under 1,000 requests a month
interface GoldPrice {
perGram: number; // fine (24K) gold, CURRENCY per gram
timestamp: number; // time of the price data (Unix seconds)
fetchedAt: number;
expiresAt: number;
}
let cached: GoldPrice | null = null;
let inflight: Promise<GoldPrice> | null = null;
// Prices only change at multiples of the plan interval (counted from 00:00 UTC).
function nextBoundary(ms: number): number {
const step = PLAN_INTERVAL_S * 1000;
return Math.floor(ms / step) * step + step;
}
async function fetchGold(): Promise<GoldPrice> {
const url = `https://metalapi.com/api/v1/latest?base=${CURRENCY}¤cies=XAU&unit=gram`;
const res = await fetch(url, { headers: { "X-API-Key": API_KEY }, signal: AbortSignal.timeout(10_000) });
const body = await res.json();
if (!body.success) throw new Error(`MetalAPI ${body.error.code}: ${body.error.info}`);
const now = Date.now();
return {
perGram: body.rates[`${CURRENCY}XAU`],
timestamp: body.timestamp,
fetchedAt: now,
expiresAt: nextBoundary(now + MIN_TTL_S * 1000) + 5_000, // a few seconds of slack
};
}
export function goldPerGram(): Promise<GoldPrice> {
if (cached && Date.now() < cached.expiresAt) return Promise.resolve(cached);
// Single flight: concurrent requests share one API call.
inflight ??= fetchGold()
.then((fresh) => (cached = fresh))
.catch((err) => {
// Keep quoting the last good price for up to 6 hours; try again in a minute.
if (cached && Date.now() - cached.fetchedAt < 6 * 3600_000) {
cached.expiresAt = Date.now() + 60_000;
return cached;
}
throw err;
})
.finally(() => (inflight = null));
return inflight;
}
export type MakingCharge =
| { type: "per_gram"; amount: number }
| { type: "percent"; rate: number } // 0.08 = 8% of the metal value
| { type: "flat"; amount: number };
const money = (n: number) => Math.round(n * 100 + Number.EPSILON) / 100;
export async function quote(grams: number, purity: number, making: MakingCharge, vatRate: number) {
const gold = await goldPerGram();
const metal = money(grams * gold.perGram * purity);
const labour = money(
making.type === "per_gram" ? grams * making.amount
: making.type === "percent" ? metal * making.rate
: making.amount,
);
const net = money(metal + labour);
const vat = money(net * vatRate);
return {
currency: CURRENCY,
grams,
purity,
gold_per_gram_24k: gold.perGram,
price_time: new Date(gold.timestamp * 1000).toISOString(),
metal,
making: labour,
net,
vat,
total: money(net + vat),
};
}A few lines deserve a closer look. nextBoundary() rounds up to the next multiple of the plan interval, so on Standard a price fetched at 10:03 expires at 10:10:05, a few seconds after the next update, rather than at 10:13. With MIN_TTL_S set to 3600 on Lite, the next refresh waits for the first boundary at least an hour away, which caps usage at 24 requests a day. The .catch branch is the serve-stale rule: it returns the last good price and schedules the next attempt a minute later, so an outage costs at most one failed request per minute instead of one per visitor.
Expose it through a small HTTP endpoint. Validate input strictly: accept only the purities you sell and a sensible weight range. The making charge and VAT rate are server-side settings; never accept them from the client.
// server.ts — npm install express, then: METALAPI_KEY=mk_live_... npx tsx server.ts
import express from "express";
import { quote } from "./gold-quote";
// Purity factors as stamped on your pieces (see the karat table above).
const PURITY: Record<string, number> = { "24": 0.999, "22": 0.916, "21": 0.875, "18": 0.75, "14": 0.585, "9": 0.375 };
const app = express();
app.get("/api/jewelry-quote", async (req, res) => {
const grams = Number(req.query.grams);
const purity = PURITY[String(req.query.karat)];
if (!purity || !(grams > 0 && grams <= 2000)) {
res.status(400).json({ error: "Use karat 24, 22, 21, 18, 14 or 9 and grams between 0 and 2000." });
return;
}
try {
// Making charge and VAT are server-side settings, never client input.
res.json(await quote(grams, purity, { type: "per_gram", amount: 12 }, 0.2));
} catch (err) {
console.error(err);
res.status(503).json({ error: "Gold price temporarily unavailable." });
}
});
app.listen(3000, () => console.log("Try http://localhost:3000/api/jewelry-quote?karat=18&grams=5.2"));A request to /api/jewelry-quote?karat=18&grams=5.2 returns a breakdown like this one (illustrative gold price):
{
"currency": "EUR",
"grams": 5.2,
"purity": 0.75,
"gold_per_gram_24k": 110,
"price_time": "2026-10-24T09:41:32.000Z",
"metal": 429,
"making": 62.4,
"net": 491.4,
"vat": 98.28,
"total": 589.68
}The Python implementation
The Python version follows the same design. It uses Decimal for money, so amounts never pick up binary floating-point errors, and a lock so that concurrent requests in a threaded server share one API call. To keep it short, it supports a per-gram making charge only:
# gold_quote.py — Python 3.9+, pip install requests
import math
import os
import threading
import time
from decimal import ROUND_HALF_UP, Decimal
import requests
API_KEY = os.environ["METALAPI_KEY"]
CURRENCY = "EUR"
PLAN_INTERVAL_S = 600 # Standard plan: prices update every 10 minutes
MIN_TTL_S = 0 # Lite plan: set 3600 to stay under 1,000 requests a month
_lock = threading.Lock()
_cache = {"per_gram": None, "timestamp": 0, "fetched": 0.0, "expires": 0.0}
def _next_boundary(t: float) -> float:
return (math.floor(t / PLAN_INTERVAL_S) + 1) * PLAN_INTERVAL_S
def gold_per_gram() -> tuple[Decimal, int]:
"""Fine (24K) gold in CURRENCY per gram, and the Unix time of that price."""
with _lock: # one API call at a time; waiting threads reuse its result
now = time.time()
if _cache["per_gram"] is not None and now < _cache["expires"]:
return _cache["per_gram"], _cache["timestamp"]
try:
r = requests.get(
"https://metalapi.com/api/v1/latest",
params={"base": CURRENCY, "currencies": "XAU", "unit": "gram"},
headers={"X-API-Key": API_KEY},
timeout=10,
)
body = r.json()
if not body.get("success"):
raise RuntimeError(f"MetalAPI {body['error']['code']}: {body['error']['info']}")
except Exception:
if _cache["per_gram"] is not None and now - _cache["fetched"] < 6 * 3600:
_cache["expires"] = now + 60 # serve the last good price, retry in a minute
return _cache["per_gram"], _cache["timestamp"]
raise
_cache.update(
per_gram=Decimal(str(body["rates"][f"{CURRENCY}XAU"])),
timestamp=body["timestamp"],
fetched=now,
expires=_next_boundary(now + MIN_TTL_S) + 5,
)
return _cache["per_gram"], _cache["timestamp"]
def money(x: Decimal) -> Decimal:
return x.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def quote(grams: Decimal, purity: Decimal, making_per_gram: Decimal, vat_rate: Decimal) -> dict:
per_gram, ts = gold_per_gram()
metal = money(grams * per_gram * purity)
making = money(grams * making_per_gram)
net = metal + making
vat = money(net * vat_rate)
return {
"currency": CURRENCY,
"gold_per_gram_24k": str(per_gram),
"price_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts)),
"metal": str(metal),
"making": str(making),
"net": str(net),
"vat": str(vat),
"total": str(net + vat),
}Any web framework can serve it. With FastAPI, plain def endpoints run in a thread pool, which is exactly what the lock is for:
# app.py — pip install fastapi uvicorn requests, then: METALAPI_KEY=mk_live_... uvicorn app:app
from decimal import Decimal
from fastapi import FastAPI, HTTPException, Query
from gold_quote import quote
# Purity factors as stamped on your pieces.
PURITY = {24: Decimal("0.999"), 22: Decimal("0.916"), 21: Decimal("0.875"),
18: Decimal("0.750"), 14: Decimal("0.585"), 9: Decimal("0.375")}
app = FastAPI()
@app.get("/api/jewelry-quote")
def jewelry_quote(karat: int, grams: Decimal = Query(gt=0, le=2000)):
if karat not in PURITY:
raise HTTPException(400, "Use karat 24, 22, 21, 18, 14 or 9.")
try:
return quote(grams, PURITY[karat], making_per_gram=Decimal("12.00"), vat_rate=Decimal("0.20"))
except Exception:
raise HTTPException(503, "Gold price temporarily unavailable.")The Python version returns amounts as strings and the TypeScript version as numbers. If a quote goes into an order system, store the amounts as decimal strings or integer cents, not as floating-point numbers.
A worked example
Here is the 18K ring priced step by step, with an illustrative gold price of €110.00 per gram, a making charge of €12 per gram and 20% VAT:
| Step | Calculation | Amount |
|---|---|---|
| Gold, 24K per gram | rates.EURXAU | €110.00 |
| Purity | 18K, hallmark 750 | 0.75 |
| Metal value | 5.2 g × €110.00 × 0.75 | €429.00 |
| Making charge | 5.2 g × €12.00 | €62.40 |
| Net price | €429.00 + €62.40 | €491.40 |
| VAT | €491.40 × 20% | €98.28 |
| Total | €589.68 |
Now a 10 g 22K chain with an 8% making charge at the same gold price. With purity 22/24, the metal value is €1,008.33, the making charge is 8% of that (€80.67), the net price is €1,089.00 and the total with VAT is €1,306.80. With the 916 hallmark factor, the metal value would be €1,007.60 instead.
Only the metal value follows the gold price, so the total moves by less than gold itself. If gold rises 1% to €111.10 per gram, the 18K ring's metal value becomes €433.29 and its total €594.83, an increase of €5.15 or 0.87%. A percentage making charge would make the total follow the gold price more closely.
Production checklist
- Keep the API key in an environment variable on the server. The calculator endpoint returns prices, never the key.
- Show
price_timewith every quote, so customers and staff can see how current the gold price is. - Fix the price when an order is placed: store the gold price, its timestamp, the purity and every line amount with the order instead of recalculating later.
- Expect flat prices over the weekend, when the gold market is closed. That is not a stale cache.
- Track usage with the usage endpoint, which does not count towards your quota, and warn yourself before the month runs out.
- Handle error
105(monthly quota used up) by serving the cached price and alerting your team. The quota resets on the 1st at 00:00 UTC, and an upgrade applies immediately. - To react to large price moves, for example to reprice your catalogue, use price alerts with webhooks instead of polling more often.
Compare plans and quotas on the pricing page, or create a free account to get a key and try the calculator with real prices.
Try it with your own data
Create a free account and make your first request in under two minutes. 100 requests a month, no credit card.
Keep reading
Gold Price API: The Complete Developer Guide
How to use a gold price API: get a key, call it from curl, JavaScript and Python, read the rates correctly, and handle history, errors, quotas and caching.
How to Add a Live Gold Price Widget to Your Website
Add a live gold price widget to WordPress, Shopify or plain HTML: build it in the MetalAPI dashboard, choose symbols, layout and theme, then paste two lines.
Gold Karat Prices Explained: 24K, 22K, 18K and How to Calculate Them
What 24K, 22K and 18K mean, how fineness marks like 916 and 750 relate, how to calculate karat gold prices per gram and how to get them from a gold price API.