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,679.98−0.12%ETH2,351.10−0.20%
すべての記事
金JavaScriptTypeScriptチュートリアル

JavaScriptで金価格APIを使う:Node.js、TypeScript、Next.js

JavaScriptで金価格APIから金価格を取得する方法。async/awaitとfetch、TypeScriptのレスポンス型、キャッシュ付きのExpress・Next.jsルート、APIキーの安全な扱い方を解説します。

執筆:MetalAPI Team··7分で読めます

JavaScriptで金価格APIを使う:最初のリクエスト

JavaScriptから金価格APIを呼び出すだけならfetchを1回使えば済みますが、本番環境の連携には、型、エラー処理、キャッシュ、そしてAPIキーの安全な置き場所も必要です。この記事では、貴金属、産業用金属、エネルギー、通貨、暗号資産を扱うJSON REST APIのMetalAPIを使って、それらをすべて構築します。まずシンプルなNode.jsスクリプト、次に型付きのTypeScriptクライアント、最後に、キーを公開せずにフロントエンドへ金価格を配信する小さなExpressまたはNext.jsのルートです。

Node.js 18以降にはfetchが組み込まれているため、HTTPライブラリは不要です。ダッシュボードでキーを作成し(キーはmk_live_で始まります)、.envファイルにMETALAPI_KEY=mk_live_...として保存して、node --env-file=.env gold.mjsでスクリプトを実行します。このファイルを読み込めるのはNode.js 20.6以降です。それより古いバージョンでは、代わりにシェルで環境変数をエクスポートしてください。

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()}`);

ドキュメントの例の値では、rates.USDXAUは3997.89で、金1トロイオンスの基準通貨建て価格です。同じオブジェクトには約0.00025のrates.XAUも含まれていますが、こちらは逆数で、1ドルあたりの金のオンス数です。これを誤って表示してしまうのが、金価格の典型的なバグです。baseをEURに変えるとキーはEURXAUになり、unit=gramを追加すると1グラムあたりの価格になります。timestampは価格データの時刻をUnix秒で表したもので、リクエストの時刻ではありません。

金価格APIレスポンスのTypeScript型

レスポンスの形は小さく安定しているため、型を付けるのは簡単です。成功したレスポンスにはsuccess: trueが含まれます。失敗したレスポンスにはsuccess: falseと、数値のcode、type、infoメッセージを持つerrorオブジェクトが含まれます。そのため、successによる判別可能なユニオン型を使えば、TypeScriptが両者を絞り込めます。このクライアントは、失敗を型付きのエラーに変換し、レート制限のレスポンスに含まれるRetry-Afterの値も保持します:

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

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

export interface LatestResponse {
  success: true;
  timestamp: number; // 価格データの時刻(Unix秒)
  date: string;
  base: string;
  unit?: string; // unitがtroy_oz以外のときに含まれる
  rates: Record<string, number>; // "USDXAU":価格、"XAU":逆数
  rate_limit: RateLimit;
}

export interface TimeframeResponse {
  success: true;
  start_date: string;
  end_date: string;
  base: string;
  rates: Record<string, Record<string, number>>; // キーは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")は、1グラムあたりのユーロ建て価格をタイムスタンプとともに返します。metalapi<TimeframeResponse>("timeframe", { start_date, end_date, currencies: "XAU" })は日次の終値を返し、1リクエストあたりFreeでは最大30日、有料プランでは最大365日分を取得できます。分岐にはerr.codeを使い、メッセージは決して使わないでください。再試行するのは、104(1分あたりの上限。retryAfter秒後に再試行)、500、503、ネットワークエラーだけです。これらの失敗したリクエストは課金されません。それ以外のコードは修正が必要です。101や102はキーがないか無効、105は月間クォータの使い切り、106はプランに含まれていないエンドポイントを意味します。

サーバーサイドかブラウザか:APIキーを置くべき場所

クライアントサイドのJavaScriptに含まれるものは、すべて公開されます。Reactのバンドル、インラインスクリプト、NEXT_PUBLIC_やVITE_の環境変数に含まれるキーは、どの訪問者でもブラウザの開発者ツールで読み取れます。さらに、ページビューのたびにリクエストを1回消費します。ユーザーに金価格を表示する方法は3つあります:

  • 自社のサーバールート(推奨)。 ブラウザは自社のエンドポイントを呼び出し、サーバーがキーを使ってMetalAPIを呼び出して結果をキャッシュします。トラフィックがどれだけ多くても、使用量はキャッシュの更新1回につき1リクエストにとどまります。
  • ブラウザでドメイン制限付きキーを使う。 ダッシュボードでキーを自社ドメインに制限でき、OriginやRefererが一致しないブラウザからのリクエストはエラー107で拒否され、課金されません。これにより、他のウェブサイトが訪問者のブラウザでキーを使うことは防げますが、キーをスクリプトにコピーする人は防げません。OriginもRefererもないリクエストは常に許可されるためです。この用途には、メインのキーではなく専用のキーを使ってください。詳細は認証ガイドをご覧ください。
  • 埋め込みウィジェット。 価格を表示するだけなら、ウィジェットを使えばキーもコードも不要で、ウィジェットのページビューはクォータにカウントされません。

Expressでキャッシュ付きの金価格APIルートを作る

サーバールートにはキャッシュが必要です。価格はプランごとに決まった間隔(Freeでは毎日、Liteでは30分ごと、Standardでは10分ごと、AdvancedとProでは60秒ごと)で更新されるため、それより早くリクエストしても同じ数値が返され、クォータを消費するだけです。次のルートは、通貨と単位の組み合わせごとに10分間キャッシュし、同時に届いたリクエストで1回の上流呼び出しを共有し、MetalAPIに到達できない場合は最後に取得できた価格を返し続けます:

server.ts
// npm install expressの後に実行:METALAPI_KEY=mk_live_... npx tsx server.ts
import express from "express";
import { goldPrice } from "./metalapi";

const TTL_MS = 10 * 60_000; // Standardプラン:価格は10分ごとに更新
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); // シングルフライト:キーごとに上流呼び出しは1回
  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 }); // 古い価格を返し、1分後に再試行
        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"));

許可リストは、安全性だけでなくクォータの面でも重要です。通貨と単位の組み合わせはそれぞれ別のキャッシュエントリーになり、更新のたびに1リクエストを消費します。6つの組み合わせを24時間キャッシュし続けると、月に約26,800リクエストかかります。リストはページに表示するものだけに絞るか、PHP・WordPressのチュートリアルのように、1回のリクエストで複数の通貨を取得してローカルで換算してください。キャッシュはプロセスのメモリ上にあります。複数のインスタンスやサーバーレス関数で動かす場合は、Redisやプラットフォームのキャッシュに移してください。

フロントエンドは、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>

Next.jsで同じルートを作る

Next.jsのApp Routerでは、Route Handlerが同じ役割を果たし、手書きのキャッシュの代わりにNext.jsのデータキャッシュを使えます。クライアントをlib/metalapi.tsとして保存し、キーは.env.localにMETALAPI_KEYとして、NEXT_PUBLIC_接頭辞を付けずに保存します。こうすれば、キーはサーバー上にしか存在しません:

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プラン:レスポンスを10分間再利用
    });
    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 }は、上流のレスポンスを最大10分間保存し、リクエスト間で共有します。Next.jsが保存するのはHTTPステータス200のレスポンスだけなので、MetalAPIのエラーがキャッシュされることはありません。アプリでCache Componentsを有効にしている場合は、代わりにfetchを'use cache'を付けたヘルパーに移し、cacheLifeで有効期間を設定してください。

次のステップ

無料アカウントを作成してキーを取得しましょう。Freeプランには月100リクエストと毎日の更新が含まれ、クレジットカードは不要です。ここで紹介した内容はすべてFreeプランで構築・テストできます。ルートを本番公開するときは、価格の鮮度とリクエスト数でプランを選んでください。10分間のキャッシュエントリー1つで月に約4,500リクエストかかり、Standardの10,000に収まります。料金ページですべてのプランを比較できます。また、Pythonで金価格APIを使うでは、同じAPIをスクリプト、pandas、グラフで使う方法を紹介しています。

よくある質問

ブラウザから金価格APIを直接呼び出せますか?

はい。APIはクロスオリジンのリクエストに応答するため、ウェブページからfetchを使えます。自社ドメインに制限したキーを使い、訪問者がそのキーを読み取れること、ページビューのたびにリクエストを消費することに留意してください。キャッシュ付きのサーバールートのほうがコストが低く、キーも非公開に保てます。

ページに金価格ではなく0.00025と表示されるのはなぜですか?

逆数のレートであるrates.XAUを読み取っています。rates.USDXAUを読み取るか、基準通貨が変わる場合はrates[base + "XAU"]を読み取ってください。例の値では、1÷0.0002501319で3997.89になります。

JavaScriptでユーロ建てや1グラムあたりの金価格を取得するには?

base=EURとunit=gramを渡します。価格はrates.EURXAUで、1グラムあたりのユーロ建てになり、レスポンスにはunit: "gram"が含まれます。unitにはtroy_oz(デフォルト)、kilogram、ounce、pound、metric_ton、short_tonも指定できます。金の場合、常衡オンスであるounceは避けてください。

金価格はどのくらいの頻度で更新すべきですか?

多くてもプランの更新間隔ごとに1回です。Freeでは毎日、Liteでは1時間ごと(30分ごとだと月に約1,490リクエストとなり、1,000のクォータを超えます)、Standardでは10分ごとです。週末は価格が動きません。スポット金は、ニューヨーク時間の金曜17:00から日曜17:00まで取引されないためです。

ポーリングせずにリアルタイムで金価格を取得できますか?

Proプラン以上では、/streamがServer-Sent Eventsで価格の更新をプッシュするため、/latestをポーリングする必要はありません。金価格が$4,000を超えたときのようなしきい値のロジックには、Webhookによる価格アラート(Advancedプラン以上)を使えば、条件を満たしたときにサーバーが呼び出されます。

ご自身のデータで試してみましょう

無料アカウントを作成すれば、2分以内に最初のリクエストを送信できます。月100リクエスト、クレジットカード不要。

関連記事