CompleteSaleで発送済みマークと追跡番号をAPIから一括登録する
前回の記事はこちら
【連載#13】eBay Trading API:CompleteSaleで発送済みマークと追跡番号をAPIから一括登録する
はじめに
本記事は、全42回にわたる「eBay API 実践ガイド」の第13回です。
前回(#12)では、GetOrders を使って注文一覧を取得し、CSV へのエクスポートや出荷管理ダッシュボードの基盤を構築しました。商品を梱包して運送業者に渡し、追跡番号(Tracking Number)を手に入れた瞬間——あなたのプログラムはそこで止まっていませんか?実は eBay は、発送が完了したという事実を API 経由で明示的に通知しなければ、「発送済み」とはみなしてくれません。
本記事で取り組む課題は、「CSV に記入した追跡番号を CompleteSale API でまとめて eBay に登録し、注文ステータスを Shipped(発送済み)に自動更新するスクリプト」の実装です。第12回のツールで取得した注文データをそのまま活用できる設計にします。
この記事で得られること:
- CompleteSale API の構造——ItemID・TransactionID・OrderID の正しい使い分けと、zeep(Python SOAP クライアント)を使った最小実装。
- 実務で必ずハマる罠——キャリアコードの厳密な指定、重複呼び出し時のエラー処理、発送済みに変更できない注文ステータスの落とし穴。
- CSV ファイルから複数注文の追跡番号を一括読み込みし、API レート制限・エラーハンドリング・リトライを考慮したプロダクションレベルのバッチスクリプト。
背景・なぜこれが重要か (Motivation)
「発送したなら、それで終わりじゃないの?」
Trading API を初めて使う開発者が最初に抱く素朴な疑問です。実際に荷物を送ったのだから、eBay も自動的に「発送済み」と判断してくれる——そう思いたいのは自然なことです。しかし現実は違います。eBay の注文管理システムは、あくまでも API やセラーハブ経由で「発送した」という通知を受け取るまで、ステータスを「Awaiting Shipment(発送待ち)」のまま保持し続けます。
CompleteSale を呼び出すと、eBay システム内部で以下が一連に発生します。(1)注文ステータスを Awaiting Shipment → Shipped に更新。(2)バイヤーへ「出品者があなたの注文を発送しました」というメール通知を自動送信。(3)追跡番号が付帯されている場合は、eBay の注文詳細ページに追跡リンクが表示される。(4)バイヤーが自分でステータスを確認できる eBay の配送トラッカー(Delivery Status)が有効化される。
この通知を怠った場合の影響は、想像以上に深刻です。
【1】バイヤー満足度の低下: バイヤーは注文確認メールを受け取った後、配送の進捗を心配します。「発送された」という通知が届かないと、不安から「商品はいつ届くの?」というメッセージが来たり、最悪の場合 Item Not Received(INR)の紛争(Case)を申請されてしまいます。
【2】eBay のセラーパフォーマンス指標への悪影響: eBay は「発送通知の迅速性(Tracking Upload)」をセラー評価の一部として計測しています。特に Top Rated Seller(TRS)ステータスを維持しているセラーにとって、発送通知の遅延が積み重なると、TRS バッジを失うリスクがあります。
【3】資金の解放遅延: eBay Managed Payments 環境では、セラーへの支払いが「発送確認後」に解放される仕組みになっています。CompleteSale を叩かないと、資金の受け取りが遅れることがあります。
これらの理由から、「出荷したらすぐに CompleteSale を叩く」ことを、バッチ処理として自動化することが生産性向上の必須要件となるのです。
基本的な使い方(ベースライン):CompleteSale の最小実装
まず、1件の注文に対して zeep で CompleteSale を呼び出す最小限のコードを示します。zeep は Python の SOAP クライアントライブラリで、eBay Trading API の WSDL を読み込み、Python のオブジェクトとして API を扱えるようにしてくれます。
インストールは以下のコマンドで行います。
pip install zeep requests
以下が最小実装のコードです。
# complete_sale_basic.py import os import requests from zeep import Client, Settings from zeep.transports import Transport WSDL_URL = "https://developer.ebay.com/webservices/latest/eBaySvc.wsdl" SITEID = "0" # eBay.com (US). 日本は 101 だが Trading API SiteID は 0 のまま def complete_sale_basic( token: str, dev_id: str, app_id: str, cert_id: str, item_id: str, transaction_id: str, tracking_number: str, carrier_code: str, ) -> dict: """ 1件の注文を発送済みにマークし、追跡番号を登録する(最小実装) Args: token: eBay User Token(OAuth 認証済み) item_id: 出品 ItemID(例: "110123456789") transaction_id: 取引 TransactionID(例: "1234567890") tracking_number: 追跡番号(例: "JD000012345678901") carrier_code: eBay 規定のキャリアコード(例: "JP_POST") Returns: zeep レスポンスオブジェクト(Ack, Errors等を含む) """ session = requests.Session() session.headers.update({ "X-EBAY-API-COMPATIBILITY-LEVEL": "1155", "X-EBAY-API-DEV-NAME": dev_id, "X-EBAY-API-APP-NAME": app_id, "X-EBAY-API-CERT-NAME": cert_id, "X-EBAY-API-SITEID": SITEID, "X-EBAY-API-CALL-NAME": "CompleteSale", "Content-Type": "text/xml", }) transport = Transport(session=session) settings = Settings(strict=False, xml_huge_tree=True) client = Client(wsdl=WSDL_URL, transport=transport, settings=settings) response = client.service.CompleteSale( RequesterCredentials={"eBayAuthToken": token}, ItemID=item_id, TransactionID=transaction_id, Shipped=True, Shipment={ "ShipmentTrackingDetails": [{ "ShipmentTrackingNumber": tracking_number, "ShippingCarrierUsed": carrier_code, }] }, ) ack = getattr(response, "Ack", "Unknown") if ack in ("Success", "Warning"): print(f"[OK] ItemID={item_id}, TransactionID={transaction_id}, Ack={ack}") else: errors = getattr(response, "Errors", []) print(f"[NG] Ack={ack}, Errors={errors}") return response if __name__ == "__main__": complete_sale_basic( token = os.environ["EBAY_USER_TOKEN"], dev_id = os.environ["EBAY_DEV_ID"], app_id = os.environ["EBAY_APP_ID"], cert_id = os.environ["EBAY_CERT_ID"], item_id = "110123456789", transaction_id = "1234567890", tracking_number= "JD000012345678901", carrier_code = "JP_POST", )
CompleteSale には Shipped と Paid という 2 種類のフラグが存在します。Shipped=True は「物理的な発送完了を通知する」フラグ、Paid=True は「支払いを受け取ったことを確認する」フラグです。現在の eBay Managed Payments 環境では、支払いは eBay が自動管理するため、Paid を手動で変更する必要はほとんどありません。本記事では Shipped=True のみを扱います。
CompleteSale は、1つの注文ラインアイテム(Order Line Item)を単位として処理します。1つのバイヤーが同じカートで複数商品を購入した場合でも、CompleteSale の呼び出しは各ラインアイテム(ItemID + TransactionID のペア)ごとに行います。OrderID はまとめて管理する際の識別子であり、CompleteSale では直接受け付けません。※ バージョンによっては OrderLineItemID(ItemID-TransactionID 形式)もサポートされていますが、本記事では最もシンプルな ItemID + TransactionID の組み合わせを使用します。
実務で躓く場面・深いポイント (Core)
ベースライン実装を本番環境で走らせると、必ずいくつかの壁にぶつかります。ここでは、実際の開発現場で頻出するエラーと落とし穴を解説します。
1. ItemID と TransactionID の対応関係の罠
第12回の GetOrders では、1件の注文(Order)の中に複数の OrderLineItem が含まれることがあります。そして、それぞれのラインアイテムには独自の ItemID と TransactionID が割り当てられています。「OrderID さえわかれば大丈夫」という考えは危険です——CompleteSale は OrderID を受け付けず、必ず ItemID と TransactionID のペアが必要です。
前回の GetOrders レスポンスでは、以下の階層でこれらの識別子を取得できます:
Order
└─ OrderID: "28-12345-67890"
└─ TransactionArray
└─ Transaction
├─ Item
│ └─ ItemID: "110123456789" ← CompleteSale に使う
└─ TransactionID: "9876543210" ← CompleteSale に使う
GetOrders の Python 処理コードでは、以下のように取得します:
# GetOrders のレスポンスから ItemID と TransactionID を抽出する for order in orders: for txn in order.TransactionArray.Transaction: item_id = txn.Item.ItemID transaction_id = txn.TransactionID # この 2 つを CSV に保存しておく
第12回で CSV に保存した際に OrderID だけを記録していた場合は、もう一度 GetOrders を叩いて TransactionID と ItemID を取得し直す必要があります。この設計ミスは非常によく見られます——最初から「ItemID + TransactionID + 追跡番号」の3列を CSV に含める設計にしてください。
2. キャリアコードは自由記述ではない——eBay 規定のコードを使う
CompleteSale の ShippingCarrierUsed フィールドには、自由なテキストを入力できるように見えますが、eBay が内部で認識してトラッキングリンクを生成できるキャリアコードは決まっています。「ヤマト運輸」「佐川急便」「日本郵便」という日本語や英語の正式名称をそのまま送ると、エラーにはならず Warning で通過してしまう一方で、バイヤーの注文ページに追跡リンクが表示されません。
eBay が認識する主要な日本関連キャリアコードは以下の通りです。GeteBayDetails API の ShippingCarrierDetails で取得することもできます。
VALID_CARRIER_CODES_JP = {
"JP_POST": "日本郵便(ゆうパック、EMS、国際eパケット等)",
"YAMATO": "ヤマト運輸(クロネコヤマト)",
"SAGAWA": "佐川急便",
"SEINO": "西濃運輸",
"NITTSU": "日本通運(ペリカン便)",
"DHL": "DHL Express",
"FEDEX": "FedEx",
"UPS": "UPS",
"USPS": "米国郵政公社(米国発送のみ)",
"TNT": "TNT Express",
"OTHER": "上記以外(追跡リンク非生成)",
}
"OTHER" を使うと追跡番号はシステムに保存されますが、バイヤーの注文ページに追跡リンクが生成されません。バイヤー体験を最大化するため、実際のキャリアに対応する正確なコードを使用してください。また、"YAMATO"・"JP_POST" など、コードの大文字小文字は eBay API が通常正規化してくれますが、念のため常に大文字で送信するのがベストプラクティスです。
3. 重複呼び出しと冪等性——すでに Shipped の注文を再送したらどうなる?
バッチ処理では、ネットワーク障害やタイムアウトによってスクリプトが途中で停止し、再実行が必要になることがあります。この時、すでに CompleteSale で Shipped にした注文をもう一度送信しようとすると何が起きるでしょうか。
結論としては、CompleteSale は同一の ItemID + TransactionID に対して Shipped=True を再送しても、eBay 側は基本的にエラーを返さず「Warning」として処理を続けます(Ack="Warning", ErrorCode=21916867 相当)。追跡番号が既に登録されている場合は、新しい追跡番号として追記される動作になります。
これは一見安全に見えますが、重複した追跡番号がバイヤーの注文ページに複数表示されてしまうという問題があります。実装上のベストプラクティスとしては、バッチ処理の結果(成功した OrderID のリスト)を CSV や DB に記録しておき、再実行時にはすでに処理済みのレコードをスキップする「べき等性(Idempotency)の確保」を行うことです。
import json from pathlib import Path PROCESSED_FILE = Path("processed_orders.json") def load_processed_orders() -> set: if PROCESSED_FILE.exists(): return set(json.loads(PROCESSED_FILE.read_text())) return set() def save_processed_order(order_id: str) -> None: processed = load_processed_orders() processed.add(order_id) PROCESSED_FILE.write_text(json.dumps(list(processed))) # バッチ処理内での使い方 processed = load_processed_orders() for record in records: if record.order_id in processed: logger.info(f"スキップ(処理済み): {record.order_id}") continue # ... CompleteSale を呼び出す ... save_processed_order(record.order_id)
頻出エラーコード早見表
以下は CompleteSale を実装する際に実際に遭遇する頻出エラーコードとその対処法です。
| エラーコード | Severity | 原因 | 対処法 |
|---|---|---|---|
| 788 | Error | ItemID または TransactionID が存在しない | GetOrders で再取得して確認する |
| 21916867 | Warning | 注文ステータスが Shipped に変更できない状態 | 注文の現在ステータスを GetOrders で確認 |
| 21917053 | Error | ShippingCarrierUsed が無効なキャリアコード | VALID_CARRIER_CODES から正しいコードを選択 |
| 21916588 | Error | OrderLineItemID のフォーマットが不正 | "ItemID-TransactionID" 形式か確認 |
| 37 | Error | eBay Auth Token が無効または期限切れ | トークンを再生成して環境変数を更新 |
エラーコード 37 は認証エラーです。User Token の有効期限は約 18 ヶ月ですが、Sandbox のトークンは 5 年のケースもあります。本番環境でのエラーコード 37 は、ほとんどの場合トークンの更新漏れが原因です。
堅牢な実装:CSV 一括追跡番号登録スクリプト
ここでは、第12回の GetOrders で生成した注文 CSV に追跡番号を追記したファイルを入力として受け取り、CompleteSale で一括処理するプロダクションレベルのスクリプトを実装します。
まず、入力 CSV のフォーマットを確認しておきましょう。第12回のスクリプトで出力した CSV に tracking_number 列と carrier_code 列を追加したものを想定します。
order_id,item_id,transaction_id,tracking_number,carrier_code 1234567890-9876543210,110123456789,9876543210,JD000012345678901,JP_POST 2345678901-8765432109,110987654321,8765432109,604123456789,YAMATO 3456789012-7654321098,111234567890,7654321098,1234567890123456789,FEDEX
以下が完全なバッチ処理スクリプトです。型アノテーション・docstring・バリデーション・エラーハンドリング・ログ出力を完備しています。
# complete_sale_batch.py """ CSVから追跡番号を一括読み込みし、CompleteSaleで発送済みマークを登録する。 Usage: export EBAY_USER_TOKEN="v^1.1..." export EBAY_DEV_ID="xxxxxxxx-xxxx-..." export EBAY_APP_ID="YourApp-..." export EBAY_CERT_ID="xxxxxxxx-xxxx-..." python complete_sale_batch.py --csv shipments.csv """ import csv import logging import os import time import argparse from dataclasses import dataclass from typing import List, Optional import requests from zeep import Client, Settings from zeep.transports import Transport from zeep.exceptions import Fault logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger(__name__) WSDL_URL = "https://developer.ebay.com/webservices/latest/eBaySvc.wsdl" SITEID = "0" # eBay が受け付けるキャリアコード(主要なもの) VALID_CARRIER_CODES = { "JP_POST", "YAMATO", "SAGAWA", "SEINO", "NITTSU", "DHL", "FEDEX", "UPS", "USPS", "TNT", "OTHER", } # ─── データクラス ───────────────────────────── @dataclass class ShipmentRecord: order_id: str item_id: str transaction_id: str tracking_number: str carrier_code: str def validate(self) -> None: """入力値の整合性を API 呼び出し前に検証する""" if not self.item_id.isdigit(): raise ValueError(f"item_id が数値でありません: '{self.item_id}'") if not self.transaction_id.isdigit(): raise ValueError(f"transaction_id が数値でありません: '{self.transaction_id}'") if not self.tracking_number.strip(): raise ValueError(f"tracking_number が空です (order_id={self.order_id})") if self.carrier_code not in VALID_CARRIER_CODES: raise ValueError( f"無効な carrier_code: '{self.carrier_code}'. " f"有効なコード: {sorted(VALID_CARRIER_CODES)}" ) # ─── CompleteSale クライアント ──────────────── class CompleteSaleClient: """zeep を使った CompleteSale の薄いラッパー""" def __init__(self, token: str, dev_id: str, app_id: str, cert_id: str): self.token = token session = requests.Session() session.headers.update({ "X-EBAY-API-COMPATIBILITY-LEVEL": "1155", "X-EBAY-API-DEV-NAME": dev_id, "X-EBAY-API-APP-NAME": app_id, "X-EBAY-API-CERT-NAME": cert_id, "X-EBAY-API-SITEID": SITEID, "X-EBAY-API-CALL-NAME": "CompleteSale", "Content-Type": "text/xml", }) transport = Transport(session=session, timeout=30) settings = Settings(strict=False, xml_huge_tree=True) self._client = Client(wsdl=WSDL_URL, transport=transport, settings=settings) def complete_sale(self, record: ShipmentRecord) -> Optional[str]: """ 1件の注文を CompleteSale で処理する。 Returns: 成功時は "Success" または "Warning"、失敗時は None。 Raises: Fault: SOAP レベルの障害 ValueError: 入力値バリデーションエラー """ record.validate() # ← API 呼び出し前に必ず検証 response = self._client.service.CompleteSale( RequesterCredentials={"eBayAuthToken": self.token}, ItemID=record.item_id, TransactionID=record.transaction_id, Shipped=True, Shipment={ "ShipmentTrackingDetails": [{ "ShipmentTrackingNumber": record.tracking_number, "ShippingCarrierUsed": record.carrier_code, }] }, ) ack = getattr(response, "Ack", "Failure") if ack == "Warning": warnings = getattr(response, "Errors", []) for w in warnings: code = getattr(w, "ErrorCode", "?") message = getattr(w, "LongMessage", "?") logger.warning(f" [WARNING] Code={code}: {message}") if ack not in ("Success", "Warning"): errors = getattr(response, "Errors", []) err_msgs = [ f"Code={getattr(e, 'ErrorCode', '?')}: {getattr(e, 'LongMessage', '?')}" for e in errors ] raise RuntimeError(f"CompleteSale 失敗 [{record.order_id}]: {err_msgs}") return ack # ─── CSV 読み込み ───────────────────────────── def load_shipments_from_csv(csv_path: str) -> List[ShipmentRecord]: """ CSV ファイルから ShipmentRecord のリストを生成する。 期待するヘッダ: order_id, item_id, transaction_id, tracking_number, carrier_code """ records = [] with open(csv_path, newline="", encoding="utf-8-sig") as f: reader = csv.DictReader(f) required_cols = {"order_id", "item_id", "transaction_id", "tracking_number", "carrier_code"} if not required_cols.issubset(set(reader.fieldnames or [])): missing = required_cols - set(reader.fieldnames or []) raise ValueError(f"CSV に必要な列が不足しています: {missing}") for row_num, row in enumerate(reader, start=2): # header=行1 records.append(ShipmentRecord( order_id = row["order_id"].strip(), item_id = row["item_id"].strip(), transaction_id = row["transaction_id"].strip(), tracking_number = row["tracking_number"].strip(), carrier_code = row["carrier_code"].strip().upper(), )) return records # ─── 一括処理メイン ─────────────────────────── def run_batch(csv_path: str, delay_sec: float = 0.5) -> None: """ CSV を読み込み、全注文に対して CompleteSale を実行する。 失敗した注文は最後にサマリ表示する。 """ client = CompleteSaleClient( token = os.environ["EBAY_USER_TOKEN"], dev_id = os.environ["EBAY_DEV_ID"], app_id = os.environ["EBAY_APP_ID"], cert_id = os.environ["EBAY_CERT_ID"], ) records = load_shipments_from_csv(csv_path) total = len(records) success = [] failures = [] logger.info(f"処理開始: {total} 件の注文を CompleteSale に送信します。") for i, record in enumerate(records, start=1): prefix = f"[{i:>4}/{total}] OrderID={record.order_id}" try: ack = client.complete_sale(record) logger.info(f"{prefix} → 成功 (Ack={ack})") success.append(record.order_id) except (Fault, RuntimeError, ValueError) as e: logger.error(f"{prefix} → 失敗: {e}") failures.append({"order_id": record.order_id, "reason": str(e)}) except Exception as e: logger.error(f"{prefix} → 予期しないエラー: {e}", exc_info=True) failures.append({"order_id": record.order_id, "reason": str(e)}) finally: # API レート制限対策: 連続呼び出し間に必ずウエイトを挟む if i < total: time.sleep(delay_sec) # ─── サマリ出力 ─────────────────────────── logger.info("=" * 60) logger.info(f"処理完了: 成功={len(success)} 件 / 失敗={len(failures)} 件 / 合計={total} 件") if failures: logger.error("以下の注文は失敗しました(手動確認が必要です):") for f in failures: logger.error(f" - OrderID={f['order_id']}: {f['reason']}") # ─── エントリポイント ───────────────────────── if __name__ == "__main__": parser = argparse.ArgumentParser(description="CompleteSale 一括処理スクリプト") parser.add_argument("--csv", required=True, help="入力CSVファイルのパス") parser.add_argument("--delay", type=float, default=0.5, help="API呼び出し間隔(秒)。デフォルト: 0.5") args = parser.parse_args() run_batch(csv_path=args.csv, delay_sec=args.delay)
このスクリプトの重要な設計ポイントを整理します。
【1】validate() メソッドによる事前検証: API を呼び出す前に、item_id が数値であるか、carrier_code が有効なコードであるか、tracking_number が空でないかを確認します。これにより、明らかに不正なデータでの API コールを防ぎ、不要な API コール数を節約します。
【2】@dataclass による型安全: ShipmentRecord を dataclass として定義することで、フィールドの存在と型が保証されます。CSV の列名変更による KeyError をコンストラクタ呼び出し時に早期検知できます。
【3】詳細なログ出力: logging モジュールを使い、各注文の処理結果をタイムスタンプ付きで記録します。100件以上の一括処理では、どの注文で問題が起きたかを素早く特定するために詳細ログが不可欠です。
【4】最終サマリの出力: 全処理後に成功・失敗の件数と失敗した OrderID のリストを明示します。失敗したレコードは手動で再処理や確認が必要なため、このサマリが運用上の重要な起点となります。
実行コマンド例は以下の通りです。
# 環境変数をセット export EBAY_USER_TOKEN="v^1.1.xxxxxx..." export EBAY_DEV_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" export EBAY_APP_ID="YourApp-xxxx-xxxx-xxxx-xxxxxxxxxxxx" export EBAY_CERT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # バッチ処理を実行(API 呼び出し間隔 0.5 秒) python complete_sale_batch.py --csv shipments.csv --delay 0.5
パフォーマンス・スケーリング視点 (深度)
1日に数十件の注文を処理するフェーズでは、上記のシンプルなシーケンシャル処理で十分です。しかし、売上が伸びて 1日あたり 200〜500 件を超えるようになると、処理速度と API レート制限の両方の観点で設計を見直す必要が出てきます。
大量注文処理の並列化と API レート制限の管理
eBay Trading API には、アカウントあたり 1 日に呼び出せるコール数の上限があります。デフォルトでは CompleteSale を含む多くの API で 1 日あたり 5,000 コールが上限です(アカウントの認定状況によって異なり、最大 150,000 コールまで申請で引き上げ可能です)。
シーケンシャル処理(delay=0.5 秒)では、1 時間あたり最大 7,200 件を処理できます。多くの場合これで十分ですが、さらに大量の注文を短時間で処理したい場合は concurrent.futures.ThreadPoolExecutor を使った並列処理が有効です。ただし、並列処理では API レート制限を超過しないよう、スレッドセーフなレートリミッターが必要です。
# complete_sale_concurrent.py(スケーリング版) import concurrent.futures import threading import time from typing import List, Tuple # 同時実行スレッド数。Trading API の 1日あたり上限 5,000 コールを # 考慮し、ピーク時でも安全なレート(例: 最大 3 並列)に抑える。 MAX_WORKERS = 3 DELAY_PER_REQ = 0.3 # 秒 _rate_lock = threading.Lock() _last_call_ts = 0.0 def _throttled_complete_sale( client: "CompleteSaleClient", record: "ShipmentRecord", ) -> Tuple[str, str]: """スロットル付きの CompleteSale 呼び出し""" global _last_call_ts with _rate_lock: now = time.monotonic() wait = DELAY_PER_REQ - (now - _last_call_ts) if wait > 0: time.sleep(wait) _last_call_ts = time.monotonic() try: ack = client.complete_sale(record) return (record.order_id, "ok") except Exception as e: return (record.order_id, f"error: {e}") def run_batch_concurrent(records: List["ShipmentRecord"], client: "CompleteSaleClient"): with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: futures = { executor.submit(_throttled_complete_sale, client, r): r for r in records } for future in concurrent.futures.as_completed(futures): order_id, result = future.result() if result == "ok": logger.info(f"[concurrent] {order_id} → 成功") else: logger.error(f"[concurrent] {order_id} → {result}")
MAX_WORKERS の値を安易に大きくしないでください。eBay のサーバーは短時間の集中コールを検知するとレートリミット(Error: request limit exceeded)を返します。実務では MAX_WORKERS=3〜5、DELAY_PER_REQ=0.3 秒程度が安全な上限の目安です。
規模がさらに大きくなり、1日 10,000 件を超える処理が必要な場合は、アーキテクチャ自体を見直す必要があります。具体的には、以下の構成を検討してください。
【分散スケジューリング】: AWS SQS や Google Cloud Tasks などのメッセージキューを導入し、CompleteSale 呼び出しをキューに積んで複数のワーカープロセスで消費する構成。1 プロセスがクラッシュしても他のプロセスが処理を継続でき、DLQ(Dead Letter Queue)に失敗レコードが貯まるため再処理が容易です。
【API コール数の申請増加】: eBay Developer Support に連絡し、利用実績を提示することで API コール上限を引き上げる申請が可能です。大規模セラーであれば、1 日あたり 50,000〜150,000 コールへの引き上げが承認されることがあります。
【Bulk Fulfillment Feeds API への移行検討】: 非常に大量の発送処理(1日 5,000 件超)が継続的に必要な場合、Trading API の CompleteSale ではなく、eBay の Fulfillment API や Order Management API(REST)への移行も検討に値します。REST API では一括操作のエンドポイントが提供されており、API コール効率が大幅に向上します。ただし、現時点(2026年)では Trading API の方が機能的に成熟しているため、移行前に機能差分を必ず確認してください。
まとめ
本記事では、出荷後の最重要タスクである「CompleteSale による発送済みマークと追跡番号の一括登録」を実装しました。
ベースライン: zeep を使った CompleteSale の最小実装で、ItemID + TransactionID のペアと正規のキャリアコードを指定し、Shipped=True で発送を通知する基本パターンを習得しました。
深いポイント: ItemID・TransactionID の正しい取得方法、eBay 規定のキャリアコード必須要件、重複呼び出し時の冪等性確保(処理済み OrderID の記録)という3つの実務の壁と、その具体的な解決策を学びました。
スケーリング: 1日 200 件超の処理では concurrent.futures によるスレッドセーフな並列化と、メッセージキューを使った分散処理アーキテクチャへの移行パスを理解しました。
CompleteSale を自動化することで、人手によるセラーハブの手動操作が不要になり、バイヤーへの発送通知が即時化されます。これは INR 紛争の予防だけでなく、セラーパフォーマンス指標(Defect Rate の改善、TRS ステータスの維持)にも直結する、EC オートメーションの中でも費用対効果の高い実装の一つです。
次のステップ
発送処理が自動化できたら、次に重要なのはバイヤーとのコミュニケーション自動化です。「商品は届きましたか?」「ご不明な点はありますか?」といったメッセージを手動で送っていませんか?
次回(#14)は、GetMemberMessages と AddMemberMessageAAQToPartner API を使って、バイヤーからのメッセージを自動取得し、テンプレートに基づいた返信を自動送信する仕組みを実装します。人手を介さない完全自動レスポンスシステムの構築にチャレンジしましょう!