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,506.91−0.33%ETH2,341.51−0.61%
All articles
GoldJavaScriptTypeScriptTutorial

Gold Price API in JavaScript: Node.js, TypeScript and Next.js

Get the gold price in JavaScript with a gold price API: async/await fetch, TypeScript response types, a cached Express or Next.js route and safe API key use.

By MetalAPI Team··7 min read

Gold price API in JavaScript: a first request

Calling a gold price API from JavaScript takes one fetch call, but a production integration also needs types, error handling, a cache and a safe place for the API key. This post builds all of that with MetalAPI, a JSON REST API for precious metals, industrial metals, energy, currencies and crypto: first a plain Node.js script, then a typed TypeScript client, then a small Express or Next.js route that serves the gold price to your frontend without exposing the key.

Node.js 18 and newer ship fetch, so no HTTP library is needed. Create a key in the dashboard (keys start with mk_live_), put it in a .env file as METALAPI_KEY=mk_live_... and run the script with node --env-file=.env gold.mjs. Node.js 20.6 and newer read the file; on older versions, export the variable in your shell instead.

gold.mjs
const params = new URLSearchParams({ base: "USD", currencies: "XAU" });
const res = await fetch(`https://metalapi.com/api/v1/latest?${params}`, {
  headers: { "X-API-Key": process.env.METALAPI_KEY },
  signal: AbortSignal.timeout(10_000),
});
const data = await res.json();
if (!data.success) throw new Error(`MetalAPI ${data.error.code}: ${data.error.info}`);

const price = data.rates[`${data.base}XAU`];
console.log(`Gold: ${price} ${data.base} per troy ounce`);
console.log(`Price time: ${new Date(data.timestamp * 1000).toISOString()}`);

With the example values from the documentation, rates.USDXAU is 3997.89: the price of one troy ounce of gold in the base currency. The same object also contains rates.XAU, about 0.00025, which is the inverse, ounces of gold per dollar. Displaying that one by mistake is the classic gold price bug. Change base to EUR and the key becomes EURXAU; add unit=gram for a price per gram. timestamp is the time of the price data in Unix seconds, not the time of your request.

TypeScript types for the gold price API response

The response shapes are small and stable, which makes them easy to type. Successful responses have success: true. Failures have success: false and an error object with a numeric code, a type and an info message, so a discriminated union on success lets TypeScript narrow between the two. This client also turns failures into a typed error that keeps the Retry-After value of rate-limit responses:

metalapi.ts
const API = "https://metalapi.com/api/v1";

export interface RateLimit {
  limit: number;
  remaining: number;
  used: number;
  reset: number; // Unix seconds
  reset_date: string;
  plan: string;
}

export interface LatestResponse {
  success: true;
  timestamp: number; // time of the price data, Unix seconds
  date: string;
  base: string;
  unit?: string; // present when unit is not troy_oz
  rates: Record<string, number>; // "USDXAU": price, "XAU": inverse
  rate_limit: RateLimit;
}

export interface TimeframeResponse {
  success: true;
  start_date: string;
  end_date: string;
  base: string;
  rates: Record<string, Record<string, number>>; // keyed by YYYY-MM-DD
  rate_limit: RateLimit;
}

export interface ErrorResponse {
  success: false;
  error: { code: number; type: string; info: string; upgrade_url?: string };
}

export class MetalApiError extends Error {
  code: number;
  type: string;
  retryAfter?: number;

  constructor(code: number, type: string, info: string, retryAfter?: number) {
    super(`MetalAPI ${code} (${type}): ${info}`);
    this.name = "MetalApiError";
    this.code = code;
    this.type = type;
    this.retryAfter = retryAfter;
  }
}

export async function metalapi<T extends { success: true }>(
  path: string,
  params: Record<string, string> = {},
): Promise<T> {
  const res = await fetch(`${API}/${path}?${new URLSearchParams(params)}`, {
    headers: { "X-API-Key": process.env.METALAPI_KEY ?? "" },
    signal: AbortSignal.timeout(10_000),
  });
  const body = (await res.json().catch(() => null)) as T | ErrorResponse | null;
  if (body === null) throw new MetalApiError(res.status, "http_error", res.statusText);
  if (body.success === false) {
    const retryAfter = Number(res.headers.get("Retry-After")) || undefined;
    throw new MetalApiError(body.error.code, body.error.type, body.error.info, retryAfter);
  }
  return body;
}

export async function goldPrice(base = "USD", unit = "troy_oz") {
  const data = await metalapi<LatestResponse>("latest", { base, currencies: "XAU", unit });
  return { price: data.rates[`${data.base}XAU`], base: data.base, unit, asOf: new Date(data.timestamp * 1000) };
}

await goldPrice("EUR", "gram") returns the euro price per gram with its timestamp, and metalapi<TimeframeResponse>("timeframe", { start_date, end_date, currencies: "XAU" }) returns daily closes for up to 30 days per request on Free and 365 on paid plans. Branch on err.code, never on the message. Retry only 104 (the per-minute limit, after retryAfter seconds), 500, 503 and network errors; those failed requests are not billed. The other codes need a fix instead: 101 or 102 mean a missing or invalid key, 105 a used-up monthly quota and 106 an endpoint your plan does not include.

Server-side or browser: where the API key belongs

Anything in client-side JavaScript is public. A key in a React bundle, an inline script or a NEXT_PUBLIC_ or VITE_ environment variable can be read by every visitor in the browser's developer tools, and every page view spends one of your requests. There are three ways to put a gold price in front of users:

  • Your own server route (recommended). The browser calls your endpoint, and your server calls MetalAPI with the key and caches the result. Usage stays at one request per cache refresh, however much traffic you get.
  • A domain-restricted key in the browser. In the dashboard you can restrict a key to your domains, and browser requests whose Origin or Referer does not match are rejected with error 107, without being billed. That stops other websites from using the key in their visitors' browsers, but not someone who copies it into a script: requests without Origin or Referer are always allowed. Use a separate key for this, never your main one. The authentication guide has the details.
  • An embeddable widget. For a plain price display, a widget needs no key and no code, and widget page views do not count against your quota.

A cached gold price API route in Express

A server route needs a cache. Each plan refreshes prices at a fixed interval (daily on Free, every 30 minutes on Lite, 10 minutes on Standard and 60 seconds on Advanced and Pro), so asking sooner returns the same numbers and only spends quota. This route caches each currency and unit for 10 minutes, lets concurrent requests share one upstream call, and keeps serving the last good price if MetalAPI cannot be reached:

server.ts
// npm install express, then: METALAPI_KEY=mk_live_... npx tsx server.ts
import express from "express";
import { goldPrice } from "./metalapi";

const TTL_MS = 10 * 60_000; // Standard plan: prices update every 10 minutes
const CURRENCIES = new Set(["USD", "EUR", "GBP"]);
const UNITS = new Set(["troy_oz", "gram"]);

type Quote = Awaited<ReturnType<typeof goldPrice>>;
const cache = new Map<string, { quote: Quote; expires: number }>();
const inflight = new Map<string, Promise<Quote>>();

function cachedGold(base: string, unit: string): Promise<Quote> {
  const key = `${base}:${unit}`;
  const hit = cache.get(key);
  if (hit && Date.now() < hit.expires) return Promise.resolve(hit.quote);

  let pending = inflight.get(key); // single flight: one upstream call per key
  if (!pending) {
    pending = goldPrice(base, unit)
      .then((quote) => {
        cache.set(key, { quote, expires: Date.now() + TTL_MS });
        return quote;
      })
      .catch((err) => {
        if (!hit) throw err;
        cache.set(key, { quote: hit.quote, expires: Date.now() + 60_000 }); // stale, retry in 1 min
        return hit.quote;
      })
      .finally(() => inflight.delete(key));
    inflight.set(key, pending);
  }
  return pending;
}

const app = express();

app.get("/api/gold", async (req, res) => {
  const base = String(req.query.currency ?? "USD").toUpperCase();
  const unit = String(req.query.unit ?? "troy_oz");
  if (!CURRENCIES.has(base) || !UNITS.has(unit)) {
    res.status(400).json({ error: "Unsupported currency or unit" });
    return;
  }
  try {
    res.set("Cache-Control", "public, max-age=60").json(await cachedGold(base, unit));
  } catch (err) {
    console.error(err);
    res.status(503).json({ error: "Gold price temporarily unavailable" });
  }
});

app.listen(3000, () => console.log("http://localhost:3000/api/gold?currency=EUR&unit=gram"));

The allowlist matters for your quota as well as for safety. Every currency and unit combination is a separate cache entry that costs one request per refresh, so six combinations kept warm around the clock take about 26,800 requests a month. Keep the list to what your pages show, or fetch several currencies in one request and convert locally, as the PHP and WordPress tutorial does. The cache lives in process memory; with several instances or serverless functions, move it to Redis or your platform's cache.

The frontend then calls your route, never MetalAPI:

index.html
<p>Gold: <strong id="gold">…</strong> <small id="gold-time"></small></p>
<script type="module">
  const res = await fetch("/api/gold?currency=EUR&unit=gram");
  if (res.ok) {
    const q = await res.json();
    const money = new Intl.NumberFormat(undefined, { style: "currency", currency: q.base });
    document.getElementById("gold").textContent = `${money.format(q.price)} per gram`;
    document.getElementById("gold-time").textContent = `as of ${new Date(q.asOf).toLocaleString()}`;
  }
</script>

The same route in Next.js

In the Next.js App Router, a Route Handler does the same job, and the Next.js data cache can replace the hand-written cache. Save the client as lib/metalapi.ts and keep the key in .env.local as METALAPI_KEY, without a NEXT_PUBLIC_ prefix, so it only exists on the server:

app/api/gold/route.ts
import type { ErrorResponse, LatestResponse } from "@/lib/metalapi";

export async function GET() {
  try {
    const res = await fetch("https://metalapi.com/api/v1/latest?currencies=XAU,XAG", {
      headers: { "X-API-Key": process.env.METALAPI_KEY ?? "" },
      next: { revalidate: 600 }, // Standard plan: reuse the response for 10 minutes
    });
    const data = (await res.json()) as LatestResponse | ErrorResponse;
    if (data.success) {
      return Response.json({ gold: data.rates.USDXAU, silver: data.rates.USDXAG, timestamp: data.timestamp });
    }
    console.error("MetalAPI error", data.error.code, data.error.info);
  } catch (err) {
    console.error(err);
  }
  return Response.json({ error: "Prices temporarily unavailable" }, { status: 503 });
}

next: { revalidate: 600 } stores the upstream response for up to 10 minutes and shares it across requests. Next.js only stores responses with HTTP status 200, so a MetalAPI error is never cached. If your app enables Cache Components, move the fetch into a helper marked 'use cache' and set its lifetime with cacheLife instead.

Next steps

Create a free account to get a key. The Free plan includes 100 requests a month with daily updates and no credit card, enough to build and test everything above. When your route goes live, pick a plan by freshness and volume: a 10-minute cache entry takes about 4,500 requests a month, which fits Standard's 10,000. The pricing page compares all plans, and Gold price API in Python covers the same API for scripts, pandas and charts.

FAQ

Can I call the gold price API directly from the browser?

Yes. The API answers cross-origin requests, so fetch works from a web page. Use a key restricted to your domains, and remember that visitors can still read it and that every page view costs a request. A server route with a cache is cheaper and keeps the key private.

Why does my page show 0.00025 instead of the gold price?

You are reading rates.XAU, the inverse rate. Read rates.USDXAU, or rates[base + "XAU"] when the base currency varies. With the example values, 1 ÷ 0.0002501319 gives 3997.89.

How do I get the gold price in euros or per gram in JavaScript?

Pass base=EUR and unit=gram. The price is then rates.EURXAU in euros per gram, and the response includes unit: "gram". unit also accepts troy_oz (the default), kilogram, ounce, pound, metric_ton and short_ton. For gold, avoid ounce, which is the avoirdupois ounce.

How often should I refresh the gold price?

Once per update interval of your plan at most: daily on Free, hourly on Lite (every 30 minutes would take about 1,490 requests a month against a quota of 1,000), every 10 minutes on Standard. Prices stay flat over the weekend, when spot gold does not trade from Friday 17:00 to Sunday 17:00 New York time.

Can I get gold prices in real time without polling?

On the Pro plan and above, /stream pushes price updates over Server-Sent Events, so you never poll /latest. For threshold logic such as gold crossing $4,000, price alerts with webhooks (Advanced plan and above) call your server when the condition is met.

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