楽天ウェブサービスのAPIで商品価格を取得しようとしたところ、古い情報どおりに実装しても一切動きませんでした。
原因は、2026年2月のリニューアルと5月14日の旧方式停止です。移行そのものの告知記事は見つかるのですが、移行後に実際に踏む落とし穴がまとまっていなかったので、動くまでに詰まった3点を残します。
Node.js の標準機能だけで動く実装も最後に置きます。
罠1: ドメインもパスも変わっている
まずここで止まります。
| 旧(2026-05-14に停止) | 新 | |
|---|---|---|
| ドメイン | app.rakuten.co.jp |
openapi.rakuten.co.jp |
| パス | /services/api/... |
/ichibams/api/... |
商品検索の新しいエンドポイントはこうなります。
https://openapi.rakuten.co.jp/ichibams/api/IchibaItem/Search/20220601
ドメインだけ直してもパスが古いままだと通りません。両方変える必要があります。
罠2: applicationId だけでは通らない。accessKey が必須
旧方式は applicationId だけで呼べましたが、新方式は2点セットです。
?applicationId=<UUID形式>&accessKey=<pk_で始まる文字列>
applicationId も形式が変わり、数字の羅列からUUID形式になりました。旧IDは使えないので、アプリの再登録が必要です。
片方だけだとこうなります。
// accessKey を付けなかった場合
{ "errors": { "errorCode": 400,
"errorMessage": "accessKey must be present as a query parameter or in the header" } }
// accessKey が正しくない場合
{ "errors": { "errorCode": 403, "errorMessage": "Invalid Access Key" } }
この2つは切り分けに使えます。 400 が返るなら渡し方の問題、403 なら値そのものの問題です。私はこれで「実装は合っていて、キーの値だけが違う」と特定できました。
accessKey は画面上で省略表示されることがある
管理画面からコピーした accessKey が 13文字しかなく、403 が消えませんでした。正しい値は 46文字です。
pk_ で始まる文字列が短い場合は、省略表示された一部だけをコピーしている可能性があります。コピーボタンがあればそれを使ってください。
罠3(最重要): アプリの種類で認証方式が違う
これが一番厄介でした。新方式のアプリには2種類あり、認証の仕組みそのものが異なります。
| 種類 | 認証方法 | 用途 |
|---|---|---|
| Webアプリケーション型 | 登録したサイトURL(Originヘッダ) | ブラウザから呼ぶ |
| バックエンドサービス型 | 登録したIPアドレス(最大9個) | サーバー/PCのプログラムから呼ぶ |
サーバーサイドのプログラムから呼ぶのに Webアプリケーション型で登録すると、キーが正しくても認証が通りません(503 Authentication service error になる例が報告されています)。
さらに、それぞれでやってはいけないことがあります。
- バックエンドサービス型に Origin ヘッダを付けてはいけない(付けると503)
- 回線の外部IPが変わると403になる → 楽天のアプリ設定で許可IPを更新する
私は最初 Webアプリケーション型で登録してしまい、種類を変えて解決しました。cronやバッチから叩くならバックエンドサービス型です。
外部IPが変わる問題への備え
IP認証である以上、回線側の都合でIPが変わると突然全滅します。気づけないのが一番まずいので、全件失敗したときに現在の外部IPを出すようにしました。
if (ok === 0 && ng > 0) {
const ip = (await (await fetch("https://api.ipify.org")).text()).trim();
console.error(`★全件失敗。現在の外部IP: ${ip}`);
console.error("楽天のアプリ設定「許可されたIPアドレス」がこれと違えば原因はそれ。");
}
エラーメッセージに次にやることまで書いておくと、数週間後の自分が助かります。
動く実装(Node.js 標準のみ・依存ゼロ)
const ENDPOINT =
"https://openapi.rakuten.co.jp/ichibams/api/IchibaItem/Search/20220601";
async function search({ appId, accessKey, affiliateId }, params) {
const url = new URL(ENDPOINT);
const q = {
applicationId: appId,
accessKey, // ← 新方式では必須
...(affiliateId ? { affiliateId } : {}),
format: "json",
formatVersion: "2", // 2 にすると Items が素直な配列になる
...params,
};
for (const [k, v] of Object.entries(q)) url.searchParams.set(k, v);
// バックエンドサービス型では Origin を付けない
const res = await fetch(url, { headers: { "User-Agent": "my-app/1.0" } });
if (!res.ok) throw new Error(`HTTP ${res.status} ${await res.text()}`);
return res.json();
}
const json = await search(cfg, { keyword: "コーヒー豆 1kg", hits: 30, sort: "standard" });
for (const it of json.Items) console.log(it.itemPrice, it.itemName);
formatVersion: "2" は地味に効きます。指定しないと Items が [{ Item: {...} }] という一段深い形で返ってくるためです。
おまけ: 「最安値」を素直に取ると、たいてい間違える
APIが通ったあとに気づいたのですが、取得した最安値がそのままでは使い物になりませんでした。
- 「オリーブオイル 1000ml」の最安値がボディソープだった
- 「ホエイプロテイン 3kg」の最安1,280円は、同じページ内の500g品の価格
- 「SSD 1TB」の最安は256GBの価格
理由は2つです。
- キーワードが偶然当たっただけの別商品が混ざる
- 楽天は複数容量を1ページにまとめた商品が多く、検索結果にはその中で最も安いバリエーションの価格が出る
対策として、商品名による除外を入れました。
const keep = (name) => {
const n = name.toLowerCase();
if (ng.some((s) => n.includes(s))) return false; // 例: ボディソープ, シャンプー
return must.every((s) => n.includes(s)); // 例: 1kg
};
もう1つ効いたのが sort を "+itemPrice" から "standard" に変えたことです。
安い順で30件取ると、上位が別容量の安い品で埋まり、本来見たい商品が1件も入らないカテゴリがありました(コーヒー豆で0件、SSDで1件)。「安い順に取れば最安が分かる」は成り立ちません。
なお、この方法でも**容量が商品名に書かれていないまとめ売りページは排除できません。**なので数字を出すときは「検索結果のうち条件に合うものの最安」と明記するのが正確です。
まとめ
- ドメインとパスの両方を新しいものに変える
-
applicationIdとaccessKeyの2点セット。400 と 403 で切り分けられる -
accessKeyが短いときは省略表示を疑う(正しくは46文字) - プログラムから叩くならバックエンドサービス型。Origin は付けない。IP変更の検知を仕込む
- 取得した最安値はそのままでは信用しない
移行そのものより、移行後に「動いているのに間違っている」状態に気づけるかのほうが手間でした。