MetalAPI
XAU3997,89−1,54 %XAG53,936−1,51 %XPT1554,47−0,83 %XPD1047,03−0,44 %XCU6,5080+2,29 %WTI103,27+3,82 %BRENT111,70−1,30 %EUR1,120400−0,01 %GBP1,337500−0,01 %JPY0,0063761+0,14 %CHF1,213445−0,25 %TRY0,020125−0,12 %BTC80.329,75−0,55 %ETH2329,36−1,13 %
Todos los artículos
TutorialComercio electrónicoTypeScriptPython

Crea una calculadora de precios de joyería con el oro en tiempo real

Crea una calculadora de precios de joyería en el servidor con TypeScript y Python: oro por gramo en tiempo real, quilates, hechura, IVA y una caché que cuida tu cuota.

Por MetalAPI Team··10 min de lectura

Qué hace la calculadora

El precio de una joya suele salir de cuatro datos: el peso de la pieza, la pureza de su oro, el precio actual del oro y la mano de obra del joyero, con los impuestos encima. Una calculadora que haga esto para cada ficha de producto o solicitud de presupuesto necesita el precio del oro por gramo en la divisa de la tienda. Además, necesita obtenerlo rápido, sin salirse de la cuota de la API y sin exponer la clave API al navegador.

Este tutorial crea un pequeño servicio en el servidor con TypeScript (Node.js) y con Python. El navegador, o el backend de tu tienda, envía un peso y un quilataje a tu propio endpoint. Tu servidor los combina con un precio del oro de MetalAPI guardado en caché y devuelve un desglose completo del precio. La fórmula:

Fórmula de precio
valor del metal = peso (g) × precio del oro por gramo (24K) × pureza
hechura         = por gramo, un porcentaje del valor del metal o un importe fijo
precio neto     = valor del metal + hechura
IVA             = precio neto × tipo de IVA
total           = precio neto + IVA

El oro al contado es un precio de referencia mayorista. Los precios de venta al público también reflejan el diseño, las piedras, la merma y el margen, y los precios de recompra suelen estar por debajo del precio al contado. Considera esta calculadora como la parte de tu precio que depende del metal y añade encima tus propias reglas de negocio.

El oro por gramo en tu divisa

El endpoint /latest devuelve precios en cualquier divisa base y, para los metales, en cualquier unidad de peso. Una sola solicitud te da el precio de un gramo de oro fino en euros:

bash
curl "https://metalapi.com/api/v1/latest?base=EUR&currencies=XAU&unit=gram" \
  -H "X-API-Key: YOUR_API_KEY"
Respuesta (valores ilustrativos)
{
  "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 es el precio de una unidad de XAU, aquí un gramo, en la divisa base. Ese es el número que necesita la calculadora. rates.XAU es el inverso: gramos de oro por euro. La respuesta incluye unit siempre que no sea la onza troy predeterminada, y timestamp es la hora de los datos de precio. Muéstrala junto a cada presupuesto.

La divisa base y la unidad funcionan en todos los planes, Free incluido, y la misma solicitud puede incluir XAG, XPT y XPD si también vendes piezas de plata o platino. Los planes de pago también pueden usar el endpoint de quilates, que devuelve directamente precios por quilate como XAU22K, o todos los quilates a la vez con carat=all. Este tutorial aplica la pureza por su cuenta para que el código funcione en cualquier plan y pueda usar los valores de ley de los contrastes, que es el siguiente tema.

Pureza, hechura e IVA

El quilate expresa el contenido de oro en veinticuatroavos, así que el factor de pureza es karat / 24, y esa es la convención que usa el endpoint /carat. Sin embargo, muchos mercados marcan el oro por su ley en milésimas, y en algunos quilates ambos valores difieren ligeramente:

Quilatesquilates / 24Ley habitual
24K1,0000999
22K0,9167916
21K0,8750875
18K0,7500750
14K0,5833585
9K0,3750375

Para un anillo de 14K de 5,2 g a un precio ilustrativo de 110 € por gramo, 14/24 da un valor del metal de 333,67 € y 0.585 da 334,62 €. Ninguno de los dos es incorrecto. Usa la convención de tus contrastes y tus facturas; por eso el código de más abajo recibe un factor de pureza en lugar de un quilataje. La guía de precios por quilate explica el contexto con más detalle.

La hechura suele adoptar una de estas tres formas, y la versión en TypeScript admite las tres:

  • Por gramo: un importe fijo por cada gramo de la pieza, por ejemplo, 12 €/g.
  • Porcentaje: una parte del valor del metal, por ejemplo, el 8 %. Sube y baja con el precio del oro.
  • Fija: un importe fijo por pieza, habitual en artículos pequeños y trabajos por encargo.

El IVA o el impuesto sobre las ventas va al final. El tipo, y a qué se aplica, depende del país: algunos gravan el precio completo, otros tratan el oro de inversión de forma distinta a la joyería y otros solo gravan la mano de obra. Guarda el tipo y la base imponible en la configuración, confírmalos con tu asesor fiscal y redondea cada línea a céntimos para que las líneas de una factura sumen exactamente su total.

Caché para no superar tu cuota

Cada plan actualiza los precios con un intervalo fijo, y las actualizaciones caen en múltiplos de ese intervalo contados desde las 00:00 UTC: a las :00, :10, :20 y así sucesivamente en el plan Standard, y una vez al día a medianoche UTC en Free. Todas las solicitudes entre dos de estos límites devuelven los mismos precios, así que la caché ideal se actualiza una vez por límite. Actualizar en cada límite, las 24 horas del día, cuesta esto en un mes de 31 días:

Plan (cuota mensual)Intervalo de actualizaciónSolicitudes en cada límite¿Dentro de la cuota?
Free (100)Diario31Sí
Lite (1000)30 min1488No: actualiza cada hora (744)
Standard (10.000)10 min4464Sí
Advanced (50.000)1 min44.640Sí, con menos margen para otras llamadas

Dos reglas más hacen que la caché sea robusta. Usa single flight: cuando la caché caduca con mucha carga, la primera solicitud obtiene un precio nuevo y las solicitudes simultáneas esperan ese resultado en lugar de llamar cada una a la API. Y sirve datos antiguos ante errores: si no se puede contactar con la API, sigue cotizando con el último precio válido durante unas horas y reintenta una vez por minuto, en lugar de hacer fallar cada compra. Las solicitudes que fallan con un error del servidor no se facturan, pero aun así no tiene sentido enviar una ráfaga de ellas durante una caída del servicio.

La caché de abajo vive en la memoria del proceso, lo ideal para un único servidor. En plataformas serverless o con varias instancias, cada instancia guarda su propia copia. Guarda el precio en un almacén compartido como Redis, o usa la caché de datos de tu framework con la misma caducidad.

La implementación en TypeScript

El módulo siguiente funciona en Node.js 18 o posterior y no tiene dependencias. Obtiene el precio en euros por gramo, lo guarda en caché hasta el siguiente límite del plan y convierte un peso, un factor de pureza y una hechura en un precio desglosado:

gold-quote.ts
// gold-quote.ts — Node 18+. La clave API se queda en el servidor.
const API_KEY = process.env.METALAPI_KEY!;
const CURRENCY = "EUR";
const PLAN_INTERVAL_S = 600; // Plan Standard: los precios se actualizan cada 10 minutos
const MIN_TTL_S = 0;         // Plan Lite: pon 3600 para no pasar de 1000 solicitudes al mes

interface GoldPrice {
  perGram: number;   // oro fino (24K), CURRENCY por gramo
  timestamp: number; // hora de los datos de precio (segundos Unix)
  fetchedAt: number;
  expiresAt: number;
}

let cached: GoldPrice | null = null;
let inflight: Promise<GoldPrice> | null = null;

// Los precios solo cambian en múltiplos del intervalo del plan (contados desde las 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}&currencies=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, // unos segundos de margen
  };
}

export function goldPerGram(): Promise<GoldPrice> {
  if (cached && Date.now() < cached.expiresAt) return Promise.resolve(cached);
  // Single flight: las solicitudes simultáneas comparten una sola llamada a la API.
  inflight ??= fetchGold()
    .then((fresh) => (cached = fresh))
    .catch((err) => {
      // Sigue cotizando con el último precio válido hasta 6 horas; reintenta en un minuto.
      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% del valor del metal
  | { 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),
  };
}

Algunas líneas merecen un vistazo más detenido. nextBoundary() redondea hacia arriba al siguiente múltiplo del intervalo del plan, de modo que en Standard un precio obtenido a las 10:03 caduca a las 10:10:05, unos segundos después de la siguiente actualización, y no a las 10:13. Con MIN_TTL_S en 3600 en Lite, la siguiente actualización espera al primer límite que esté al menos a una hora de distancia, lo que limita el consumo a 24 solicitudes al día. La rama .catch es la regla de servir datos antiguos: devuelve el último precio válido y programa el siguiente intento un minuto después, así que una caída cuesta como mucho una solicitud fallida por minuto en lugar de una por visitante.

Exponlo mediante un pequeño endpoint HTTP. Valida la entrada de forma estricta: acepta solo las purezas que vendes y un rango de peso razonable. La hechura y el tipo de IVA son ajustes del servidor; nunca los aceptes del cliente.

server.ts
// server.ts — npm install express y después: METALAPI_KEY=mk_live_... npx tsx server.ts
import express from "express";
import { quote } from "./gold-quote";

// Factores de pureza tal como figuran en el contraste de tus piezas (ver la tabla de quilates).
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 {
    // La hechura y el IVA son ajustes del servidor, nunca datos del cliente.
    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"));

Una solicitud a /api/jewelry-quote?karat=18&grams=5.2 devuelve un desglose como este (precio del oro ilustrativo):

json
{
  "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
}

La implementación en Python

La versión en Python sigue el mismo diseño. Usa Decimal para los importes, de modo que nunca arrastran errores de coma flotante binaria, y un cerrojo (lock) para que las solicitudes simultáneas en un servidor con hilos compartan una sola llamada a la API. Para que sea breve, solo admite una hechura por gramo:

gold_quote.py
# 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  # Plan Standard: los precios se actualizan cada 10 minutos
MIN_TTL_S = 0          # Plan Lite: pon 3600 para no pasar de 1000 solicitudes al mes

_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]:
    """Oro fino (24K) en CURRENCY por gramo y la hora Unix de ese precio."""
    with _lock:  # una llamada a la API cada vez; los hilos en espera reutilizan su resultado
        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  # sirve el último precio válido y reintenta en un minuto
                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),
    }

Cualquier framework web puede servirlo. Con FastAPI, los endpoints definidos con def normal se ejecutan en un pool de hilos, que es justo para lo que sirve el cerrojo:

app.py
# app.py — pip install fastapi uvicorn requests y después: METALAPI_KEY=mk_live_... uvicorn app:app
from decimal import Decimal

from fastapi import FastAPI, HTTPException, Query

from gold_quote import quote

# Factores de pureza tal como figuran en el contraste de tus piezas.
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.")

La versión en Python devuelve los importes como cadenas y la versión en TypeScript como números. Si un presupuesto acaba en un sistema de pedidos, guarda los importes como cadenas decimales o como céntimos enteros, no como números de coma flotante.

Un ejemplo completo

Este es el precio del anillo de 18K paso a paso, con un precio ilustrativo del oro de 110,00 € por gramo, una hechura de 12 € por gramo y un IVA del 20 %:

PasoCálculoImporte
Oro, 24K por gramorates.EURXAU110,00 €
Pureza18K, ley 7500,75
Valor del metal5,2 g × 110,00 € × 0,75429,00 €
Hechura5,2 g × 12,00 €62,40 €
Precio neto429,00 € + 62,40 €491,40 €
IVA491,40 € × 20 %98,28 €
Total589,68 €

Ahora, una cadena de 22K de 10 g con una hechura del 8 % al mismo precio del oro. Con una pureza de 22/24, el valor del metal es de 1008,33 €, la hechura es el 8 % de esa cifra (80,67 €), el precio neto es de 1089,00 € y el total con IVA, de 1306,80 €. Con el factor de la ley 916, el valor del metal sería de 1007,60 €.

Solo el valor del metal sigue al precio del oro, así que el total se mueve menos que el propio oro. Si el oro sube un 1 %, hasta 111,10 € por gramo, el valor del metal del anillo de 18K pasa a 433,29 € y su total a 594,83 €, un aumento de 5,15 €, es decir, del 0,87 %. Con una hechura porcentual, el total seguiría más de cerca al precio del oro.

Lista de comprobación para producción

  • Guarda la clave API en una variable de entorno del servidor. El endpoint de la calculadora devuelve precios, nunca la clave.
  • Muestra price_time con cada presupuesto, para que clientes y empleados vean lo reciente que es el precio del oro.
  • Fija el precio al registrar un pedido: guarda con el pedido el precio del oro, su marca de tiempo, la pureza y el importe de cada línea, en lugar de recalcularlos más tarde.
  • Cuenta con precios planos durante el fin de semana, cuando el mercado del oro está cerrado. No es una caché desactualizada.
  • Haz seguimiento del consumo con el endpoint de uso, que no cuenta para tu cuota, y avísate antes de que se acabe el mes.
  • Gestiona el error 105 (cuota mensual agotada) sirviendo el precio en caché y alertando a tu equipo. La cuota se restablece el día 1 a las 00:00 UTC, y un cambio a un plan superior se aplica de inmediato.
  • Para reaccionar a grandes movimientos de precio, por ejemplo para actualizar los precios de tu catálogo, usa alertas de precio con webhooks en lugar de consultar con más frecuencia.

Compara planes y cuotas en la página de precios, o crea una cuenta gratuita para obtener una clave y probar la calculadora con precios reales.

Pruébalo con tus propios datos

Crea una cuenta gratuita y haz tu primera solicitud en menos de dos minutos. 100 solicitudes al mes, sin tarjeta de crédito.

Sigue leyendo