MetalAPI
XAU3 997,89−1,54 %XAG53,936−1,51 %XPT1 554,47−0,83 %XPD1 047,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 %ETH2 329,36−1,13 %
Tous les articles
TutorielE-commerceTypeScriptPython

Créer un calculateur de prix de bijoux avec le cours de l'or en direct

Créez en TypeScript et en Python un calculateur de prix de bijoux côté serveur : or au gramme en direct, carats, façon, TVA et cache adapté à votre quota.

Par MetalAPI Team··10 min de lecture

Ce que fait le calculateur

Le prix d'un bijou se calcule généralement à partir de quatre éléments : le poids de la pièce, le titre de son or, le cours actuel de l'or et la main-d'œuvre du bijoutier, auxquels s'ajoutent les taxes. Un calculateur qui fait ce travail pour chaque fiche produit ou demande de devis a besoin du prix de l'or au gramme dans la devise de la boutique. Il lui faut aussi ce prix rapidement, dans les limites du quota de l'API et sans exposer la clé API au navigateur.

Ce tutoriel construit un petit service côté serveur en TypeScript (Node.js) et en Python. Le navigateur, ou le backend de votre boutique, envoie un poids et un nombre de carats à votre propre endpoint. Votre serveur les combine avec un cours de l'or mis en cache provenant de MetalAPI et renvoie le détail complet du prix. La formule :

Formule de prix
valeur du métal = poids (g) × prix de l'or au gramme (24K) × titre
façon           = au gramme, en pourcentage de la valeur du métal ou forfaitaire
prix HT         = valeur du métal + façon
TVA             = prix HT × taux de TVA
total           = prix HT + TVA

Le cours spot de l'or est un prix de référence de gros. Les prix de détail reflètent aussi le design, les pierres, les pertes de fabrication et la marge, et les prix de rachat sont généralement inférieurs au spot. Considérez ce calculateur comme la partie de votre tarification liée au métal et ajoutez-y vos propres règles métier.

L'or au gramme dans votre devise

L'endpoint /latest renvoie les prix dans n'importe quelle devise de base et, pour les métaux, dans n'importe quelle unité de masse. Une seule requête vous donne le prix d'un gramme d'or fin en euros :

bash
curl "https://metalapi.com/api/v1/latest?base=EUR&currencies=XAU&unit=gram" \
  -H "X-API-Key: YOUR_API_KEY"
Réponse (valeurs indicatives)
{
  "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 est le prix d'une unité de XAU, ici un gramme, dans la devise de base. C'est la valeur dont le calculateur a besoin. rates.XAU en est l'inverse : des grammes d'or par euro. La réponse inclut unit chaque fois que l'unité n'est pas l'once troy par défaut, et timestamp correspond à l'heure des données de prix. Affichez-le à côté de chaque devis.

La devise de base et l'unité fonctionnent sur tous les plans, Free compris, et la même requête peut inclure XAG, XPT et XPD si vous vendez aussi des pièces en argent ou en platine. Les plans payants peuvent également utiliser l'endpoint carat, qui renvoie directement les prix par carat comme XAU22K, ou tous les carats d'un coup avec carat=all. Ce tutoriel applique lui-même le titre, pour que le code fonctionne sur n'importe quel plan et puisse utiliser les titres en millièmes des poinçons, ce qui nous amène au point suivant.

Titre, façon et TVA

Le carat exprime la teneur en or en vingt-quatrièmes : le facteur de pureté vaut donc karat / 24, et c'est la convention qu'utilise l'endpoint /carat. De nombreux marchés poinçonnent toutefois l'or selon son titre en millièmes, et pour certains carats les deux valeurs diffèrent légèrement :

Caratcarat / 24Poinçon courant
24K1,0000999
22K0,9167916
21K0,8750875
18K0,7500750
14K0,5833585
9K0,3750375

Pour une bague en or 14 carats de 5,2 g, avec un cours indicatif de 110 € le gramme, 14/24 donne une valeur du métal de 333,67 € et 0.585 donne 334,62 €. Aucune des deux n'est fausse. Utilisez la convention de vos poinçons et de vos factures : c'est pourquoi le code ci-dessous prend un facteur de pureté plutôt qu'un nombre de carats. Le guide des prix par carat approfondit le sujet.

La façon prend généralement l'une de ces trois formes, et la version TypeScript les gère toutes :

  • Au gramme : un montant fixe par gramme de la pièce, par exemple 12 €/g.
  • Pourcentage : une part de la valeur du métal, par exemple 8 %. Elle monte et baisse avec le cours de l'or.
  • Forfait : un montant fixe par pièce, courant pour les petits articles et le travail sur mesure.

La TVA ou la taxe sur les ventes vient en dernier. Le taux, et l'assiette à laquelle il s'applique, dépendent du pays : certains taxent le prix total, d'autres traitent l'or d'investissement différemment des bijoux, d'autres encore ne taxent que la main-d'œuvre. Gardez le taux et le montant imposable dans votre configuration, faites-les valider par votre expert-comptable et arrondissez chaque ligne au centime pour que les lignes d'une facture correspondent à son total.

Mettre en cache pour respecter votre quota

Chaque plan met à jour les prix à un intervalle fixe, et ces mises à jour tombent sur des multiples de cet intervalle comptés à partir de 00:00 UTC : à :00, :10, :20 et ainsi de suite sur le plan Standard, et une fois par jour à minuit UTC sur Free. Toutes les requêtes entre deux de ces échéances renvoient les mêmes prix : le cache idéal se rafraîchit donc une fois par échéance. Se rafraîchir à chaque échéance, jour et nuit, coûte ceci sur un mois de 31 jours :

Plan (quota mensuel)Intervalle de mise à jourRequêtes à chaque échéanceDans le quota ?
Free (100)Quotidien31Oui
Lite (1 000)30 min1 488Non : rafraîchir plutôt toutes les heures (744)
Standard (10 000)10 min4 464Oui
Advanced (50 000)1 min44 640Oui, avec moins de marge pour les autres appels

Deux règles supplémentaires rendent le cache robuste. Utilisez un appel unique partagé (single flight) : quand le cache expire sous charge, la première requête récupère un nouveau prix et les requêtes simultanées attendent ce résultat au lieu d'appeler chacune l'API. Et servez l'ancien prix en cas d'erreur (serve stale on error) : si l'API est injoignable, continuez à coter avec le dernier prix valide pendant quelques heures et réessayez une fois par minute, au lieu de faire échouer chaque passage en caisse. Les requêtes qui échouent avec une erreur serveur ne sont pas facturées, mais rien ne justifie pour autant d'en envoyer une rafale pendant une panne.

Le cache ci-dessous vit dans la mémoire du processus, ce qui est idéal pour un serveur unique. Sur les plateformes serverless ou avec plusieurs instances, chaque instance garde sa propre copie. Placez le prix dans un stockage partagé comme Redis, ou utilisez le cache de données de votre framework avec la même expiration.

L'implémentation TypeScript

Le module ci-dessous fonctionne avec Node.js 18 ou plus récent et n'a aucune dépendance. Il récupère le prix en euros au gramme, le met en cache jusqu'à la prochaine échéance du plan et transforme un poids, un facteur de pureté et une façon en un prix détaillé :

gold-quote.ts
// gold-quote.ts — Node 18+. La clé API reste sur le serveur.
const API_KEY = process.env.METALAPI_KEY!;
const CURRENCY = "EUR";
const PLAN_INTERVAL_S = 600; // Plan Standard : prix mis à jour toutes les 10 minutes
const MIN_TTL_S = 0;         // Plan Lite : mettez 3600 pour rester sous 1000 requêtes par mois

interface GoldPrice {
  perGram: number;   // or fin (24K), CURRENCY par gramme
  timestamp: number; // heure des données de prix (secondes Unix)
  fetchedAt: number;
  expiresAt: number;
}

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

// Les prix ne changent qu'aux multiples de l'intervalle du plan (comptés depuis 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, // quelques secondes de marge
  };
}

export function goldPerGram(): Promise<GoldPrice> {
  if (cached && Date.now() < cached.expiresAt) return Promise.resolve(cached);
  // Single flight : les requêtes simultanées partagent un seul appel à l'API.
  inflight ??= fetchGold()
    .then((fresh) => (cached = fresh))
    .catch((err) => {
      // Continuer à coter le dernier prix valide jusqu'à 6 heures ; réessayer dans une 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 % de la valeur du métal
  | { 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),
  };
}

Quelques lignes méritent qu'on s'y attarde. nextBoundary() arrondit au multiple suivant de l'intervalle du plan : sur Standard, un prix récupéré à 10:03 expire donc à 10:10:05, quelques secondes après la mise à jour suivante, et non à 10:13. Avec MIN_TTL_S réglé sur 3600 sur Lite, le rafraîchissement suivant attend la première échéance située à au moins une heure, ce qui plafonne la consommation à 24 requêtes par jour. La branche .catch applique la règle « servir l'ancien prix » : elle renvoie le dernier prix valide et programme la tentative suivante une minute plus tard, si bien qu'une panne coûte au plus une requête échouée par minute au lieu d'une par visiteur.

Exposez-le via un petit endpoint HTTP. Validez strictement les entrées : n'acceptez que les titres que vous vendez et une plage de poids raisonnable. La façon et le taux de TVA sont des réglages côté serveur ; ne les acceptez jamais du client.

server.ts
// server.ts — npm install express, puis : METALAPI_KEY=mk_live_... npx tsx server.ts
import express from "express";
import { quote } from "./gold-quote";

// Facteurs de pureté tels que poinçonnés sur vos pièces (voir le tableau des carats ci-dessus).
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 façon et la TVA sont des réglages côté serveur, jamais des entrées du client.
    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"));

Une requête vers /api/jewelry-quote?karat=18&grams=5.2 renvoie un détail comme celui-ci (cours de l'or indicatif) :

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
}

L'implémentation Python

La version Python suit la même conception. Elle utilise Decimal pour les montants, afin qu'ils ne subissent jamais d'erreurs de virgule flottante binaire, et un verrou pour que les requêtes simultanées d'un serveur multithread partagent un seul appel à l'API. Pour rester concise, elle ne gère qu'une façon au gramme :

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 : prix mis à jour toutes les 10 minutes
MIN_TTL_S = 0          # Plan Lite : mettez 3600 pour rester sous 1000 requêtes par mois

_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]:
    """Or fin (24K) en CURRENCY par gramme, et l'heure Unix de ce prix."""
    with _lock:  # un appel à l'API à la fois ; les threads en attente réutilisent son résultat
        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  # servir le dernier prix valide, réessayer dans une 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),
    }

N'importe quel framework web peut le servir. Avec FastAPI, les endpoints def classiques s'exécutent dans un pool de threads, et c'est précisément à cela que sert le verrou :

app.py
# app.py — pip install fastapi uvicorn requests, puis : METALAPI_KEY=mk_live_... uvicorn app:app
from decimal import Decimal

from fastapi import FastAPI, HTTPException, Query

from gold_quote import quote

# Facteurs de pureté tels que poinçonnés sur vos pièces.
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 version Python renvoie les montants sous forme de chaînes, la version TypeScript sous forme de nombres. Si un devis alimente un système de commandes, stockez les montants en chaînes décimales ou en centimes entiers, pas en nombres à virgule flottante.

Un exemple chiffré

Voici la bague 18 carats chiffrée étape par étape, avec un cours indicatif de 110,00 € le gramme, une façon de 12 € par gramme et une TVA de 20 % :

ÉtapeCalculMontant
Or 24K au grammerates.EURXAU110,00 €
Titre18K, poinçon 7500,75
Valeur du métal5,2 g × 110,00 € × 0,75429,00 €
Façon5,2 g × 12,00 €62,40 €
Prix HT429,00 € + 62,40 €491,40 €
TVA491,40 € × 20 %98,28 €
Total589,68 €

Prenons maintenant une chaîne en or 22 carats de 10 g avec une façon de 8 %, au même cours. Avec un titre de 22/24, la valeur du métal est de 1 008,33 €, la façon représente 8 % de ce montant (80,67 €), le prix HT s'élève à 1 089,00 € et le total TTC à 1 306,80 €. Avec le facteur du poinçon 916, la valeur du métal serait de 1 007,60 €.

Seule la valeur du métal suit le cours de l'or : le total varie donc moins que l'or lui-même. Si l'or gagne 1 % pour atteindre 111,10 € le gramme, la valeur du métal de la bague 18 carats passe à 433,29 € et son total à 594,83 €, soit une hausse de 5,15 € ou 0,87 %. Avec une façon en pourcentage, le total suivrait le cours de l'or de plus près.

Liste de contrôle pour la production

  • Gardez la clé API dans une variable d'environnement sur le serveur. L'endpoint du calculateur renvoie des prix, jamais la clé.
  • Affichez price_time avec chaque devis, pour que clients et personnel voient à quel point le cours de l'or est récent.
  • Figez le prix au moment de la commande : stockez avec la commande le cours de l'or, son horodatage, le titre et chaque montant de ligne au lieu de les recalculer plus tard.
  • Attendez-vous à des prix figés le week-end, quand le marché de l'or est fermé. Ce n'est pas un cache périmé.
  • Suivez votre consommation avec l'endpoint usage, qui n'est pas décompté de votre quota, et prévoyez une alerte avant la fin du mois.
  • Gérez l'erreur 105 (quota mensuel épuisé) en servant le prix mis en cache et en alertant votre équipe. Le quota est réinitialisé le 1er à 00:00 UTC, et un passage à un plan supérieur prend effet immédiatement.
  • Pour réagir aux fortes variations de prix, par exemple pour actualiser les prix de votre catalogue, utilisez les alertes de prix avec webhooks plutôt que d'interroger l'API plus souvent.

Comparez les plans et les quotas sur la page des tarifs, ou créez un compte gratuit pour obtenir une clé et essayer le calculateur avec des prix réels.

Essayez avec vos propres données

Créez un compte gratuit et envoyez votre première requête en moins de deux minutes. 100 requêtes par mois, sans carte bancaire.

À lire aussi