eBay Sell REST API:Trading APIからREST APIへ:OAuth移行と最初のInventory API呼び出し

前回の記事はこちら

【連載#24】eBay Sell REST API:Trading APIからREST APIへ:OAuth移行と最初のInventory API呼び出し

はじめに

本記事は、全42回にわたる「eBay API 実践ガイド」の第24回です。

第3回から第23回まで、私たちは Trading API(SOAP形式)と Message API を中心に、出品・在庫管理・注文処理・バルクメッセージ対応まで幅広い実装を積み重ねてきました。今回からは新しいフェーズ【Sell REST - 出品】の幕開けとして、モダンな REST API の世界へと本格的に軸足を移します。

第1回で構築した OAuth 2.0 認証基盤がいよいよ本領を発揮します。Trading API 時代の devID / appID / certID + UserToken という複雑な認証の組み合わせに代わり、すっきりとした Bearer Token でのAPI呼び出しが標準となります。第1回で実装した eBayTokenManager クラスはそのまま活用できます。

この記事で得られること:

  • Inventory API の設計思想(SKU 中心設計)と Trading API(ItemID 中心設計)との根本的な構造の違いを理解し、なぜ3ステップの出品フローになるのかを把握する。
  • OAuth Bearer Token を使った PUT /inventory_item/{sku} の実際の呼び出し方を、最小構成から生産レベルの実装まで段階的に習得する。
  • 「完全上書き」仕様による意図しないデータ消失、1日250回の修正上限、Inventory API と Trading API 間の非互換性という移行時の3大トラップと、その具体的な回避策を身につける。

背景・なぜこれが重要か (Motivation)

「Trading API がまだ使えるなら、わざわざ REST API に移行する必要ある?」

これは非常に正直な疑問です。確かに現時点では Trading API も動作しており、第3〜17回で構築したシステムは今日でも稼働しています。しかしこの判断を先延ばしにし続けることには、無視できない技術的リスクが潜んでいます。

eBay の API ロードマップは明確に REST と GraphQL API への集中投資を宣言しています。新機能(Promoted Listings の高度な入札制御、次世代の Offer 管理、Dynamic Shipping 等)は REST / GraphQL API でのみ提供されるケースが増え続けており、Trading API 側にはバックポートされません。言い換えれば、Trading API に留まり続けることは「今のシステムを維持できても、eBay マーケットプレイスの進化についていけなくなる」リスクを恒常的に抱えることを意味します。

もう一つの現実的な懸念は「SOAP の学習コスト」です。Trading API が SOAP ベースであるため、新しいチームメンバーのオンボーディングや外部ライブラリとの連携に余分なコストがかかります。REST API への移行は、将来の開発速度を高めるための先行投資でもあります。

本連載では、Trading API の知識を持つ読者が混乱なく Inventory API へ移行できるよう、両者の対応関係を丁寧に解説していきます。第3〜23回で積み上げた知識は無駄になりません。UUIDによる冪等性・バリデーションファースト・エラーハンドリングといったエンジニアリング思想はそのまま REST の世界でも通用します。

基本的な使い方(ベースライン):createOrReplaceInventoryItemで最初のInventory Itemを作る

Inventory API の最初のエンドポイントは PUT /inventory_item/{sku}(operationId: createOrReplaceInventoryItem)です。このメソッドは SKU をキーとして、商品の在庫数・コンディション・商品詳細をeBayのシステムに登録します。Trading API の AddFixedPriceItem(第4回)と大きく異なるのは、この時点では「まだeBayサイトには出品されていない」という点です。まずは最小構成で動作を確認しましょう。

必須ヘッダーは Authorization(Bearer Token)、Content-Type(application/json)、そして Content-Language の3つです。Content-Language は日本向けの出品であっても en-US の指定が基本です。この設定を忘れると 400 エラーで弾かれます。

# inventory_item_basic.py
import requests

EBAY_INVENTORY_BASE = "https://api.ebay.com/sell/inventory/v1"


def create_inventory_item(sku: str, token: str) -> dict:
    """最小構成でInventory Itemを作成する。

    Args:
        sku:   出品者が定義するSKU(最大50文字、全在庫でユニーク)
        token: 第1回で取得したOAuth 2.0 Bearer Token

    Returns:
        {"status_code": int, "sku": str}
        201 = 新規作成成功 / 204 = 既存SKUへの更新成功
    """
    url = f"{EBAY_INVENTORY_BASE}/inventory_item/{sku}"
    headers = {
        "Authorization":  f"Bearer {token}",
        "Content-Type":   "application/json",
        "Content-Language": "en-US",  # 日本向けでも en-US が基本
    }
    payload = {
        "availability": {
            "shipToLocationAvailability": {
                "quantity": 10
            }
        },
        "condition": "NEW",
        "product": {
            "title": "Classic Denim Jacket - Size M",
            "description": "<p>A classic vintage denim jacket.</p>",
            "aspects": {
                "Brand": ["Levi's"],
                "Size":  ["M"],
                "Color": ["Blue"]
            },
            "imageUrls": [
                "https://i.ebayimg.com/images/g/example/jacket_main.jpg"
            ]
        }
    }
    resp = requests.put(url, headers=headers, json=payload)
    resp.raise_for_status()
    return {"status_code": resp.status_code, "sku": sku}


if __name__ == "__main__":
    TOKEN = "v^1.1#i^1#..."  # 第1回のOAuthフローで取得
    SKU = "JACKET-DENIM-M-001"
    result = create_inventory_item(SKU, TOKEN)
    print(f"HTTP {result['status_code']} - SKU: {result['sku']}")
    # 201: 新規作成成功(レスポンスボディなし)
    # 204: 既存SKUへの更新成功(レスポンスボディなし)
補足: SKU 中心設計と ItemID 中心設計の違い

Trading API では、出品後に eBay が発行する ItemID がすべての操作の起点でした(ReviseFixedPriceItem・GetItem・EndFixedPriceItem、いずれも ItemID 指定)。一方 Inventory API は、出品者が事前に定義した SKU がすべての操作の起点になります。「在庫は自分のシステムで管理するもの」というパラダイムシフトが根底にあります。SKU は最大50文字で、自分の全在庫を通じてユニークである必要があります。例えばeBayとAmazonで同じ商品を扱う場合、"EBAY-JP-JACKET-001" のようにプラットフォームプレフィックスを付ける設計も実務では有効です。

実務で躓く場面・深いポイント (Core)

Inventory API は Trading API と比べてシンプルに見えますが、その設計の根本にある「分離の思想」と仕様上のいくつかの落とし穴を理解しないまま実装を進めると、後で深刻な問題を引き起こします。経験者が必ずぶつかる3大トラップを解説します。

1. Inventory Itemを作っただけでは、商品はまだeBayに出品されていない

これが Inventory API を初めて使う Trading API 経験者が最初に混乱するポイントです。Trading API の AddFixedPriceItem(第4回)は「1回のAPIコールで出品完了」するモノリシックな設計でした。しかし Inventory API は責務を分離した設計になっており、eBayサイト上に商品を公開するまでに最低3つのステップが必要です。

ステップ1 — createOrReplaceInventoryItem(本記事の内容): 「この商品はどんなものか・在庫は何個か」というマスターデータをeBayのシステムに登録する。

ステップ2 — createOffer(次回 #25 の内容): 「この商品をどのマーケットプレイスに・いくらで・どのポリシーで売るか」というオファー条件を設定する。

ステップ3 — publishOffer(次回 #25 の内容): オファーを実際に公開し、eBayのサイト上に商品が表示される状態にする。

本記事では Inventory Item の作成(ステップ1)のみを扱います。ステップ1が完了した後、eBay のセラーハブ(Seller Hub)の「Inventory」タブでは商品が登録済みとして確認できますが、「Active Listings」タブには表示されません。これは正常な動作です。あわてて同じ SKU で AddFixedPriceItem を呼び出すと、後述する「非互換性の罠」に陥ります。

2. 「完全上書き」の仕様を知らずに既存データを消してしまう事故

Inventory API の PUT /inventory_item/{sku} はべき等(Idempotent)なメソッドですが、「部分更新」ではなく「完全上書き(Full Replace)」です。送信したペイロードの内容がそのままレコードに保存され、送らなかったフィールドは削除されます。

例えば、最初に product.upc(JANコード)を含めて Inventory Item を登録しておき、後から在庫数(availability.shipToLocationAvailability.quantity)だけを変更しようとして UPC を含まないペイロードを送ると、UPC の情報がまるごと消えてしまいます。eBay の Product Catalog とのマッチング精度が落ち、検索順位への影響も懸念されます。

この事故を防ぐベストプラクティスは、更新の前に必ず GET /inventory_item/{sku} で現在の完全なデータを取得し、変更したい箇所だけをマージした上で PUT を送ることです。後述の「堅牢な実装」セクションの safe_upsert() がこのパターンを実装しています。なお eBay 公式ドキュメントもこの「取得してからマージして更新」のアプローチをベストプラクティスとして明記しています。

注意: 1日250回の修正上限(Daily Modification Cap)

Inventory Item の作成・更新には、1日あたり最大250回というレート制限が設けられています。この上限はセラーアカウント単位で適用され、上限に達するとその日はそれ以上の更新がブロックされます。数千件の SKU を持つセラーが一斉に全商品の在庫を更新しようとすると、この上限に引っかかることがあります。

大量更新が必要な場合は、bulkCreateOrReplaceInventoryItem(POST /inventory_item/bulk_create_or_replace)を活用してください。1回のAPIコールで最大25件のInventory Itemを同時に作成・更新できるため、リクエスト消費を大幅に削減できます。また優先度ロジック(在庫残数が少ないものを先に更新するなど)を実装し、重要なアイテムから処理する設計を推奨します。

3. Inventory APIとTrading APIの出品は「別の世界」:混在運用が招くカオス

これが移行期に最も危険なトラップです。Inventory API で createOrReplaceInventoryItem → createOffer → publishOffer の手順で作成した出品は、Trading API の ReviseFixedPriceItem・RelistFixedPriceItem・EndFixedPriceItem では操作できません。逆に Trading API の AddFixedPriceItem で作成した出品は、Inventory API の updateOffer・endOffer では操作できません。

この非互換性を知らずに、「既存の Trading API 出品は Trading API で管理しながら、新商品だけ Inventory API で登録する」という並行運用を安易に始めると、数ヶ月後に「どの SKU がどちらのAPIで管理されているか把握できない」という管理の破綻を招きます。ReviseFixedPriceItem を呼んだら 404 が返ってきて初めて気づく、という状況は実務では致命的です。

移行の原則として、1つの SKU は必ずどちらか一方のAPI系統で管理するという鉄則を徹底してください。既存商品を Inventory API へ移行するときは、まず Trading API 側で EndFixedPriceItem を呼び出して出品を終了させてから、Inventory API で新規登録するのが安全な手順です。

頻出エラーコード早見表

HTTPステータス / エラー 主な原因 対処法
400 / 25002 SKU が50文字超、または空文字・禁止文字を含む SKU を英数字・ハイフン・アンダースコアのみ・50文字以内に整形する
400 / 25003 condition の値が ConditionEnum に存在しない(例: "Used" をそのまま送った) CONDITION_MAP で Trading API 値を "USED_EXCELLENT" 等のInventory API値に変換する
400 / 25004 product.title が80文字を超えている title[:80] でトリミングしてから送る(Python スライス)
401 / Unauthorized Bearer Token の有効期限切れ(有効期限は2時間) 第1回実装の eBayTokenManager.get_valid_token() で自動リフレッシュする
404 / 25013 GET 時に指定した SKU の Inventory Item が存在しない get_inventory_item() で None チェックし、新規作成フローへ分岐する

堅牢な実装:Trading APIデータ構造からInventory Item形式への安全な移行アダプター

ここでは、Trading API(AddFixedPriceItem)時代のデータ構造を Inventory API のペイロードに変換するアダプター関数と、「完全上書き」問題を回避する safe_upsert() メソッドを実装します。型アノテーション・docstring・入力バリデーション・ログ出力を含む生産レベルの実装です。

TradingItemData は第3〜17回の Trading API 実装で使ってきたデータモデルの典型例を dataclass で表現したものです。adapt_trading_to_inventory_item() がアダプター関数で、TradingItemData を Inventory API のペイロード dict に変換します。InventoryItemManager クラスの safe_upsert() では、更新前に既存データを GET で取得し、ディープマージしてから PUT を送ることで「完全上書き」による意図しないフィールド消失を防ぎます。

# inventory_manager.py
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any

import requests

logger = logging.getLogger(__name__)

EBAY_INVENTORY_BASE = "https://api.ebay.com/sell/inventory/v1"

# Trading API コンディション文字列 → Inventory API ConditionEnum マッピング
CONDITION_MAP: dict[str, str] = {
    "New":                      "NEW",
    "Used":                     "USED_EXCELLENT",
    "Very Good":                "USED_VERY_GOOD",
    "Good":                     "USED_GOOD",
    "Acceptable":               "USED_ACCEPTABLE",
    "For parts or not working": "FOR_PARTS_OR_NOT_WORKING",
}


@dataclass
class TradingItemData:
    """Trading API(AddFixedPriceItem)時代のデータ構造を表すデータクラス。

    第3〜17回のTrading API実装で使ってきたデータモデルを想定。
    """
    sku:         str
    title:       str
    description: str
    condition:   str                        # Trading API 形式 (例: "New")
    quantity:    int
    brand:       str
    image_urls:  list[str]
    aspects:     dict[str, list[str]] | None = None
    upc:         str | None = None
    ean:         str | None = None


def adapt_trading_to_inventory_item(data: TradingItemData) -> dict[str, Any]:
    """Trading API データ構造を Inventory API ペイロードに変換するアダプター。

    Args:
        data: Trading API 形式のアイテムデータ

    Returns:
        createOrReplaceInventoryItem に渡すペイロード dict

    Raises:
        ValueError: condition が CONDITION_MAP に存在しない場合
    """
    condition = CONDITION_MAP.get(data.condition)
    if condition is None:
        raise ValueError(
            f"未対応のコンディション: '{data.condition}'. "
            f"対応値: {list(CONDITION_MAP.keys())}"
        )

    aspects: dict[str, list[str]] = data.aspects or {"Brand": [data.brand]}
    if "Brand" not in aspects:
        aspects["Brand"] = [data.brand]

    product: dict[str, Any] = {
        "title":       data.title[:80],       # eBay タイトル最大80文字
        "description": data.description,
        "aspects":     aspects,
        "imageUrls":   data.image_urls[:12],  # 最大12枚
    }
    if data.upc:
        product["upc"] = data.upc
    if data.ean:
        product["ean"] = data.ean

    return {
        "availability": {
            "shipToLocationAvailability": {"quantity": data.quantity}
        },
        "condition": condition,
        "product":   product,
    }


def _deep_merge(base: dict, override: dict) -> dict:
    """辞書を再帰的にマージする(override 側の値が優先される)。"""
    result = dict(base)
    for key, val in override.items():
        if (key in result
                and isinstance(result[key], dict)
                and isinstance(val, dict)):
            result[key] = _deep_merge(result[key], val)
        else:
            result[key] = val
    return result


class InventoryItemManager:
    """Inventory API の /inventory_item を安全に操作するクライアント。"""

    def __init__(self, token: str) -> None:
        self._session = requests.Session()
        self._session.headers.update({
            "Authorization":   f"Bearer {token}",
            "Content-Type":    "application/json",
            "Content-Language": "en-US",
        })

    def get_inventory_item(self, sku: str) -> dict[str, Any] | None:
        """既存のInventory Itemを取得する。存在しない場合はNoneを返す。

        Args:
            sku: 取得対象のSKU

        Returns:
            Inventory Item の dict、存在しない場合は None
        """
        url = f"{EBAY_INVENTORY_BASE}/inventory_item/{sku}"
        resp = self._session.get(url)
        if resp.status_code == 404:
            return None
        resp.raise_for_status()
        return resp.json()

    def safe_upsert(
        self,
        sku: str,
        new_payload: dict[str, Any],
    ) -> dict[str, Any]:
        """「完全上書き」仕様に対応した安全な Inventory Item 作成・更新。

        既存レコードを取得してディープマージしてから PUT を行うことで、
        意図せずフィールドを消してしまう事故を防ぐ。

        Args:
            sku:         作成・更新対象のSKU(最大50文字、全在庫でユニーク)
            new_payload: adapt_trading_to_inventory_item() で生成したペイロード

        Returns:
            {"sku": str, "created": bool, "status_code": int}

        Raises:
            ValueError: SKU が50文字を超える場合
            requests.HTTPError: API がエラーを返した場合
        """
        if len(sku) > 50:
            raise ValueError(f"SKU は50文字以内にしてください: '{sku}'")

        # 完全上書き対策: 先に既存データを取得してマージする
        existing = self.get_inventory_item(sku)
        if existing is not None:
            logger.info("SKU '%s' 既存データ取得 → マージして更新します", sku)
            payload = _deep_merge(existing, new_payload)
        else:
            logger.info("SKU '%s' → 新規作成します", sku)
            payload = new_payload

        url = f"{EBAY_INVENTORY_BASE}/inventory_item/{sku}"
        resp = self._session.put(url, json=payload)
        resp.raise_for_status()

        is_created = (resp.status_code == 201)
        logger.info(
            "SKU '%s': %s 完了 (HTTP %d)",
            sku, "作成" if is_created else "更新", resp.status_code,
        )
        return {"sku": sku, "created": is_created, "status_code": resp.status_code}


# ── 使用例 ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    from ebay_token_manager import eBayTokenManager  # 第1回実装済みクラス

    tm = eBayTokenManager()
    token = tm.get_valid_token()
    manager = InventoryItemManager(token)

    # Trading API 時代のデータ(移行元)
    old_data = TradingItemData(
        sku="JACKET-DENIM-M-001",
        title="Classic Denim Jacket Size M",
        description="<p>Vintage denim jacket in mint condition.</p>",
        condition="New",
        quantity=5,
        brand="Levi's",
        image_urls=["https://i.ebayimg.com/images/g/example/jacket.jpg"],
        aspects={"Brand": ["Levi's"], "Size": ["M"], "Color": ["Blue"]},
        upc="012345678901",
    )

    # アダプターで変換 → 安全にupsert
    payload = adapt_trading_to_inventory_item(old_data)
    result = manager.safe_upsert(old_data.sku, payload)
    print(f"完了: SKU={result['sku']}, 新規作成={result['created']}")
補足: eBayTokenManager との連携とセッション再利用

safe_upsert() 内の GET → PUT の2ステップの間にトークンが失効することは稀ですが、長時間バッチ処理を行う場合はセッション開始時に get_valid_token() でリフレッシュしておくことを推奨します。requests.Session() を使い回すことで TCP 接続のオーバーヘッドを削減し、大量の SKU を処理するバッチのスループットを向上させています。1,000件を超えるバッチ処理では、セッションの再利用だけで処理時間が20〜30%改善するケースもあります。

パフォーマンス・スケーリング視点 (深度)

Trading APIとInventory APIの並行運用戦略と段階的移行ロードマップ

数千〜数万件の商品を扱う実務では、一夜にして全商品を Inventory API へ移行するのは現実的ではありません。稼働中のシステムを止めずに段階的に移行するための3フェーズ戦略を解説します。

【フェーズ1: 棚卸しと分類(〜2週間)】まず、現在 Trading API で管理している全 SKU を棚卸しします。「アクティブな出品(現在売れているもの)」「長期間売れていない在庫」「新規追加予定の商品」の3カテゴリに分類します。このフェーズではAPIの変更はまだ行いません。最重要の成果物は、各 SKU に対して api_type("trading" or "inventory")と ebay_item_id を記録する管理テーブルです。このテーブルが後のフェーズで生命線になります。

【フェーズ2: 新規商品からInventory APIを適用(〜1ヶ月)】新しく出品する商品はすべて Inventory API(createOrReplaceInventoryItem → createOffer → publishOffer)で登録します。既存の Trading API 出品はそのまま維持します。このフェーズで管理テーブルへの記録ロジックを実装し、「どの SKU がどちらのAPI管轄か」を常に把握できる状態にします。

【フェーズ3: 既存商品の段階的移行(1〜3ヶ月)】優先度の低い商品(長期在庫・売上の少ないもの)から順に移行します。各商品の移行手順は: (1) Trading API 側で EndFixedPriceItem を呼び出して出品を終了 → (2) eBay のシステムが終了を処理するまで数分待機 → (3) adapt_trading_to_inventory_item() でデータ変換 → (4) safe_upsert() で Inventory Item 登録 → (5) createOffer → publishOffer(次回 #25 で実装)で再出品、の順です。1件ずつ完結させ、管理テーブルを更新してから次の商品に進みます。

1日250回の修正上限との兼ね合いも重要です。フェーズ3で大量移行を行う場合、1日に何件移行できるかをあらかじめ計算し、上限の8割程度(200件/日)を目安に処理件数を制限するロジックを実装してください。上限に達した場合は処理を翌日に持ち越すキューイング機構が必要です。

並行運用期間中の最大のリスクは「APIの取り違え」です。管理テーブルを参照せずにコードをハードコードすると、Trading API 管理の商品に対して Inventory API の更新を呼んで 404 が返り、そのエラーを握りつぶした結果として在庫が同期されないという「サイレント障害」が発生します。管理テーブルのルックアップは必ずAPIコールの前に行い、型チェック(api_type のバリデーション)も実装してください。

まとめ

本記事では、連載の新フェーズ【Sell REST - 出品】の幕開けとして、Trading API から Inventory API への移行の第一歩を実装しました。

ベースライン: OAuth Bearer Token を使い PUT /inventory_item/{sku}(createOrReplaceInventoryItem)を呼び出す最小構成を確認し、SKU 中心設計という Inventory API の根本的なパラダイムを理解しました。

深いポイント: Inventory Item を作成しただけではまだ出品されない(Offer が必要な3ステップ設計)、「完全上書き」仕様による意図しないデータ消失事故、1日250回の修正上限と bulk エンドポイントによる効率化、そして Inventory API と Trading API 間の出品非互換性という4つの重要なトラップとその回避策を解説しました。

スケーリング: 数万件規模の既存 Trading API 商品を、サービスを止めずに Inventory API へ移行するための3フェーズ戦略と、並行運用期間中の管理テーブルによる SKU 追跡の重要性を解説しました。

第1〜23回で積み上げたエンジニアリング思想(冪等性・バリデーションファースト・エラーハンドリング)は、Inventory API の実装においても変わらず重要です。むしろそれらの思想がベースとなることで、モダンな REST API への移行がスムーズに進みます。

次のステップ

次回(#25)では、本記事で登録した Inventory Item に対して createOffer と publishOffer を呼び出し、ついに eBay のサイト上に商品を公開する手順を解説します。価格・配送ポリシー・返品ポリシーの設定方法と、Offer のステータス管理(PUBLISHED / UNPUBLISHED)の詳細についても深く掘り下げます。お楽しみに!

トップに戻る