×

《亚马逊 SP-API:落地方案、接口边界与业务踩坑 —— “年费 $1400 + 超量 $0.40/千次” 的真相与 2026 反转》(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-17 10:05:53 浏览10 评论0

抢沙发发表评论

《亚马逊 SP-API:落地方案、接口边界与业务踩坑 —— “年费 0.40/千次” 的真相与 2026 反转》(附 Python 源码)

0. 先纠正一个时间线(很重要)

你标题里的 “年费 0.40/千次” 是亚马逊 2025-11-03 公布、原定 2026-01-31 / 2026-04-30 生效 的收费方案,但:
  • 2026-01:延期

  • 2026-03:无限期推迟

  • 2026-05-12:Amazon 正式邮件宣布 “will not move forward with the SP-API usage and annual fees at this time”

所以 2026-10-17 今天写这篇文章,正确姿态是:
把 0.40 当“成本模型教材”和“未来可能再来一次的防御设计”,但不要把生产架构建立在这个账单一定存在上。
Amazon 原话留了 at this time,以后换种形态收费完全可能。
原方案回顾(仅作历史/压测口径):
项目
数值
年费(第三方开发者)
$1400 / 年
Basic 档包含 GET
250 万次 / 月
超量 GET
$0.40 / 1000 次
Pro / Plus
10000 月费
PUT/POST/PATCH
不按量计费,但受限流
自营私域 App(只给自己店铺用)
不收开发者费

一、SP-API 和前面几家根本不是一类东西

平台
模型
计费
1688 / 淘宝 / 京东 / 拼多多 / 抖店
国内开放平台,按调用/云内云外
预充值 / 塔内塔外 / 云外×10
亚马逊 SP-API
LWA OAuth + Role/App 授权 + REST + 通知/报告分离
原拟收费,现免费,但限流极狠
SP-API 核心组成:
  • Auth:Login with Amazon(LWA)→ access_token(约 1h)+ refresh_token(长期)

  • Selling Partner API:orders / catalog / listings / inventory / feeds / reports / notifications / finances / shipments

  • EventBridge / SQS 通知:ORDER_CHANGE、REPORT_PROCESSING_FINISHED 等

  • Reports API:建报告 → 轮询状态 → 下载文档(大批量正道)

  • Dynamic Usage Plan(DUP):你看到的文档限流是最低档,Amazon 会根据历史自动调


二、订单接口边界(最容易被国内团队写错)

接口
角色
限流(默认)
getOrders
按 CreatedAfter / LastUpdatedAfter 拉订单列表
0.0167 rps = 1/min,burst 20
getOrder
单订单头信息
0.5 rps / burst 30
getOrderItems
订单行项目(价格/数量/ASIN/买家配送)
0.5 rps / burst 30
Orders Report
批量订单 CSV
建报告 1/min,下载另算
ORDER_CHANGE 通知
订单变更事件
不耗 getOrders 额度
⚠️ 国内团队最常犯的错:
每 5 秒调一次 getOrders 查新单 → 10 秒内把 burst 打光 → 后面全 429
Amazon 官方结论:
日单 < 2 万:getOrders + getOrderItems 可行
大单量:ORDER_CHANGE 通知 + Orders Report
还有一条隐藏规则:
LastUpdatedAfter 查到的时间是 “当前时间 - 2 分钟”,新单有 2 分钟延迟。

三、生产架构:通知优先,Reports 批量,REST 兜底

EventBridge / SQS
  └─ ORDER_CHANGE (orderId, changeType)
        └─ 入队 (amazonOrderId)

Worker:
  getOrder(orderId)         # 订单头
  getOrderItems(orderId)    # 订单行
  → 写 order 快照

每 30~60 min:
  Orders Report (GET_FLAT_FILE_ORDER_REPORT)
    → 批量对账,补漏

财务/库存:
  GET_FBA_MYI_UNSUPPRESSED_INVENTORY_DATA
  GET_LEDGER_SUMMARY
  → Reports API,不逐 SKU 调 getInventorySummaries
规则(和淘宝 DSS / 京东 JMQ / 抖店推送同构):
通知做触发器,Report 做批量真相,getOrder 做单条补全,轮询 getOrders 只做兜底。*

四、Python:LWA 令牌管理(refresh 不互踢)

# spapi/auth.py
import time
import requests


class LwaTokenProvider:
    def __init__(self, client_id, client_secret, refresh_token,
                 redis=None, ttl_margin=300):
        self.client_id = client_id
        self.client_secret = client_secret
        self.refresh_token = refresh_token
        self.redis = redis
        self.margin = ttl_margin
        self._cache = {}

    def _redis_key(self):
        return f"spapi:lwa:access:{self.client_id}"

    def get_access_token(self) -> str:
        if self.redis:
            tok = self.redis.get(self._redis_key())
            if tok:
                return tok

        resp = requests.post("https://api.amazon.com/auth/o2/token", data={
            "grant_type": "refresh_token",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "refresh_token": self.refresh_token,
        }, timeout=5)
        resp.raise_for_status()
        data = resp.json()
        token = data["access_token"]
        expires_in = int(data.get("expires_in", 3600))

        if self.redis:
            self.redis.set(self._redis_key(), token, ex=expires_in - self.margin)
        return token
多实例用 Redis 锁防并发刷新:
def refresh_with_lock(provider, r):
    lock = r.lock("spapi:lwa:refresh:lock", timeout=10)
    if lock.acquire(blocking=False):
        try:
            r.delete(provider._redis_key())
            provider.get_access_token()
        finally:
            lock.release()

五、Python:限流感知的 SP-API 客户端

# spapi/client.py
import time
import requests


class SpApiThrottleError(Exception):
    def __init__(self, retry_after):
        super().__init__("throttled")
        self.retry_after = retry_after


class SpApiClient:
    def __init__(self, token_provider, region_endpoint,
                 rate_per_sec=0.0167, burst=20):
        self.tp = token_provider
        self.base = region_endpoint   # NA/EU/FE 不同
        self.rate = rate_per_sec
        self.burst = burst
        self._tokens = burst
        self._last = time.monotonic()

    def _acquire(self):
        while True:
            now = time.monotonic()
            self._tokens = min(
                self.burst,
                self._tokens + (now - self._last) * self.rate
            )
            self._last = now
            if self._tokens >= 1:
                self._tokens -= 1
                return
            time.sleep(0.2)

    def _request(self, method, path, params=None, retries=5):
        for i in range(retries):
            self._acquire()
            token = self.tp.get_access_token()
            headers = {
                "x-amz-access-token": token,
                "Accept": "application/json",
            }
            url = f"{self.base}{path}"
            resp = requests.request(method, url, headers=headers,
                                    params=params, timeout=10)
            if resp.status_code == 429:
                ra = int(resp.headers.get("Retry-After", 60))
                time.sleep(ra + (i * 2))
                continue
            if resp.status_code >= 500:
                time.sleep(2 ** i)
                continue
            resp.raise_for_status()
            return resp.json()
        raise RuntimeError("sp-api retry exhausted")

    def get_orders(self, last_updated_after, next_token=None):
        p = {"MarketplaceIds": "...", "LastUpdatedAfter": last_updated_after}
        if next_token:
            p["NextToken"] = next_token
        return self._request("GET", "/orders/v0/orders", p)

    def get_order_items(self, amazon_order_id):
        return self._request("GET", f"/orders/v0/orders/{amazon_order_id}/orderItems")

六、订单同步:通知 + Report + getOrders 兜底

# spapi/order_sync.py
from datetime import datetime, timedelta


class SpOrderSync:
    def __init__(self, client, repo, dedup):
        self.client = client
        self.repo = repo
        self.dedup = dedup

    def on_order_change(self, amazon_order_id: str):
        # 通知只给 id,不信任通知体里的状态
        self.dedup.mark(f"order_change:{amazon_order_id}")
        self.fill_order(amazon_order_id)

    def fill_order(self, amazon_order_id):
        head = self.client._request("GET", f"/orders/v0/orders/{amazon_order_id}")
        items = self.client.get_order_items(amazon_order_id)
        snap = self.normalize(head, items)
        self.apply(snap)
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
    def poll_compensation(self, marketplace_ids, now: datetime):
        # 结束时间留 2 分钟缓冲(Amazon 订单有 2 分钟延迟)
        end = now - timedelta(minutes=2)
        start = end - timedelta(minutes=30)
        next_token = None
        while True:
            resp = self.client.get_orders(
                last_updated_after=start.isoformat(),
                next_token=next_token,
            )
            for o in resp["payload"]["Orders"]:
                self.on_order_change(o["AmazonOrderId"])
            next_token = resp["payload"].get("NextToken")
            if not next_token:
                break
            # getOrders 默认 1/min,别在循环里硬跑:
            time.sleep(60)

七、Reports 模式:大单量别用 getOrders

# spapi/reports.py
class SpReportClient:
    def __init__(self, client):
        self.client = client

    def pull_orders_report(self, marketplace_ids, start, end):
        create = self.client._request("POST", "/reports/2021-06-30/reports", {
            "reportType": "GET_FLAT_FILE_ORDER_REPORT",
            "marketplaceIds": marketplace_ids,
            "dataStartTime": start.isoformat(),
            "dataEndTime": end.isoformat(),
        })
        report_id = create["reportId"]

        # 轮询状态:别狂轮,指数退避
        for _ in range(30):
            status = self.client._request(
                "GET", f"/reports/2021-06-30/reports/{report_id}"
            )["payload"]
            if status["processingStatus"] == "DONE":
                return self.download(status["reportDocumentId"])
            time.sleep(min(60, 5 * (2 ** _)))
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
    def download(self, doc_id):
        doc = self.client._request(
            "GET", f"/reports/2021-06-30/documents/{doc_id}"
        )["payload"]
        # doc["url"] 是带签名的临时地址
        r = requests.get(doc["url"])
        return r.text  # TSV/CSV
降本效果:
  • 1 万订单:getOrderItems 可能 1 万次 GET

  • Orders Report:建 1 次 + 状态轮询 ~5 次 + 下载 1 次 = 7 次调用


八、限流 / 错误分类(Amazon 特有)

现象
类型
处理
429 QuotaExceeded
限流
读 Retry-After,指数退避
403 Access Denied
授权/Roles 错
不重试,查 IAM/Data Rights
404
orderId 错 / 区域错
不重试
500/503
平台抖动
退避重试
x-amzn-RateLimit-Limit
当前档位
监控用
DUP 提额
历史好
别依赖,仍要做退避
Amazon 限流算法 = token bucket,但每个 operation 独立桶,且按 account × app 双维度限流。

九、“如果真收 $0.40/千次” 的防御代码

# spapi/cost_meter.py
class SpApiGetMeter:
    def __init__(self, redis, app_id, budget_usd_per_month=50.0):
        self.r = redis
        self.key = f"spapi:get:cost:{app_id}:{month_now()}"
        self.price_per_call = 0.40 / 1000

    def count(self, n=1):
        total = self.r.incrby(self.key, n)
        cost = total * self.price_per_call
        if cost > 50.0:
            raise BudgetExceeded(f"SP-API GET 预算将超: ${cost:.2f}")

    def estimated_cost(self):
        return int(self.r.get(self.key) or 0) * self.price_per_call
调用侧:
meter.count(1)
resp = client.get_orders(...)
规则:
  • GET 才计费,POST/PUT 不按量

  • Reports 把 N 次 GET 压成几次 → 天然省钱

  • 通知替代轮询 → 把“心跳轮询”成本降到 0

  • 多卖家共享 App → 调用量聚合,更容易撞档 → 私域 App / 多 App 分租户要算清


十、和前几篇收口

  • 1688:secret 头尾 MD5,QPS 卡脖

  • 淘宝:聚石塔内外 10 倍,DSS 推送免费

  • 京东:云鼎内稳,POP 时间窗

  • 拼多多/抖店:预充值,云外 ×10,增值禁外

  • 亚马逊 SP-API:不限国内云内云外概念,限流极狠 + 通知/报告分离 + 原拟 0.40 但 2026-05 取消

一句话:
国内平台是“钱换调用”,亚马逊是“限流换架构”。
你用国内 ERP 的轮询本能写 SP-API,会先被 429 打服,再被(可能的)账单打醒。
SP-API 的正解永远是:EventBridge 叫你、Reports 扛批量、getOrder 只补刀、NextToken 别硬翻、2 分钟延迟写进时间窗。*


群贤毕至

访客