Amazon Selling Partner API in production: what the docs don't tell you
I built an Amazon SP API integration from scratch for a multi-brand fulfillment system. Here are the real challenges: three-layer authentication, per-account rate limiting, feed polling inside Lambda, and idempotent webhook subscriptions.
Amazon Selling Partner API is MWS’s replacement — more modern, more consistent, better documented. In theory.
In practice, I spent weeks figuring out why requests came back 403, why the rate limiter behaved differently per endpoint, and how to poll a feed without timing out a Lambda.
This article covers what I learned building an SP API integration for a fulfillment system handling orders across multiple EU marketplaces and multiple Amazon seller accounts.
Context
The app manages three main flows:
- Orders: Amazon notifies via webhook → we fetch full order details → normalize and publish to EventBridge for the fulfillment system
- Inventory: stock change events → bulk update across all EU marketplaces via Feeds API
- Tracking: confirm shipment to Amazon after the warehouse has processed the order
Each flow touches SP API differently, and each has its quirks.
Authentication: three layers, three tokens, three expiries
This is where most guides abandon you after the first paragraph.
SP API uses three authentication mechanisms in sequence:
1. LWA (Login with Amazon) — OAuth 2.0 to get an access_token. Needs refreshing every hour or so, and the token varies by grant_type (refresh_token for order operations, client_credentials with specific scopes for notifications).
2. AWS STS AssumeRole — SP API requires assuming a specific IAM role (SP_API_ROLE_ARN) to get temporary AWS credentials. They expire every hour.
3. SigV4 — Every HTTP request must be signed with the STS credentials using standard AWS request signing.
The challenge isn’t understanding each step individually — it’s managing three tokens with three different expiry times robustly.
private async ensureAccessToken(grantType: OAuthGrantType, scope?: OAuthScope): Promise<void> {
const now = Date.now();
const needsRefresh =
!this.accessToken ||
!this.accessTokenExpiry ||
now > this.accessTokenExpiry ||
this.accessTokenGrantType !== grantType ||
(this.accessTokenScope || null) !== (scope || null);
if (needsRefresh) {
try {
await this.generateAccessToken(grantType, scope);
} catch {
// retry once — LWA can be slow under burst
await this.generateAccessToken(grantType, scope);
}
}
}
private async ensureRoleCredentials(): Promise<void> {
const now = Date.now();
const bufferMs = 5 * 60 * 1000; // refresh 5 minutes before expiry
if (!this.roleCredentials || now + bufferMs > this.roleCredentialsExpiry) {
await this.generateRoleCredentials();
}
}
Three non-obvious things here:
- The 5-minute buffer on STS credentials avoids race conditions: if credentials expire while a request is in-flight, you get a 403 that’s hard to diagnose.
- The
access_tokenmust be cached separately bygrant_typeand scope. Using aclient_credentialstoken whererefresh_tokenis expected returns a 403 with no useful message. - A single retry on token fetch is enough — LWA has bursts of slowness, not extended outages.
Rate limiting: it’s not optional
Amazon publishes rate limits for every endpoint in the docs. What they don’t clearly state is that limits apply per seller account, not per application.
If you’re managing multiple seller accounts from the same Lambda, you must isolate the rate limiter per account — otherwise one account’s requests consume another account’s quota.
I used a token bucket per endpoint:
private createRateLimiter(): RateLimiter {
return new RateLimiter([
// Feeds API — the most critical, very low limits
{ apiName: SPApiEndpoint.CREATE_FEED, refillRate: 0.0083, maxTokens: 15, sleepTime: 1000 },
{ apiName: SPApiEndpoint.CREATE_FEED_DOCUMENT, refillRate: 0.5, maxTokens: 15 },
{ apiName: SPApiEndpoint.GET_FEED, refillRate: 0.0222, maxTokens: 10 },
{ apiName: SPApiEndpoint.GET_FEED_DOCUMENT, refillRate: 0.0222, maxTokens: 10 },
]);
}
refillRate: 0.0083 means roughly one request every two minutes. CREATE_FEED is the most restrictive endpoint — Amazon allows very few feed creations per hour. Exceed it and you get a 429 that blocks inventory updates across all marketplaces.
Each seller account creates its own rate limiter instance. The Lambda caches instances in memory between invocations (warm Lambda), so the limiter doesn’t restart from scratch on every SQS message.
Feeds API: polling inside Lambda
To update inventory across multiple marketplaces in bulk, SP API uses the Feeds API. The flow is:
- Create a
feedDocument— get a pre-signed URL for upload - Upload content as JSON Lines (one object per line) to that URL
- Create the feed with the
feedDocumentId— get afeedId - Poll
GET /feeds/{feedId}untilprocessingStatusisDONEorFATAL - Download the report (GZIP), parse it to find per-SKU errors
Step 4 is the critical point: Amazon doesn’t notify you when the feed is ready. You have to poll.
private async waitForFeedProcessingAndVerify(feedId: string): Promise<BulkUpdateError[]> {
const maxAttempts = 24;
const pollInterval = 10_000; // 10 seconds
let attempts = 0;
while (attempts < maxAttempts) {
const feed = await this.amazonClient.getFeed(feedId);
if (feed.processingStatus === FeedProcessingStatus.DONE) {
if (feed.resultFeedDocumentId) {
return this.verifyFeedReport(feed.resultFeedDocumentId);
}
return [];
}
if (feed.processingStatus === FeedProcessingStatus.FATAL) {
throw new Error(`Feed FATAL: ${feedId}`);
}
await sleep(pollInterval);
attempts++;
}
throw new Error(`Feed timeout after 240s: ${feedId}`);
}
24 attempts × 10 seconds = 4 minutes max wait, well within the Lambda timeout (10 minutes). In practice, most feeds complete in 30–60 seconds. The worst case I saw was during Amazon internal issues — the feed hit FATAL after the timeout.
An alternative would be Step Functions with waitForTaskToken — start the feed, pass the token as a callback URL, and pause execution until notified. SP API doesn’t support this pattern, so polling is the only option.
The final report is a GZIP file with one JSON line per processed SKU. Parsing it to extract errors requires gunzipSync — straightforward, but not shown in the official examples.
Webhooks: idempotency is mandatory
To receive order notifications, you register a “destination” (your SQS ARN) and subscribe to the ORDER_CHANGE notification type.
The problem: createDestination and createSubscription aren’t idempotent. Call setup again (say, on every deploy) and Amazon creates duplicates — you receive each notification multiple times.
async subscribeToWebhooks(queueArn: string): Promise<void> {
const destinations = await this.getDestinations();
let destinationId = destinations
.find(d => d.resource.sqs.arn === queueArn)
?.destinationId;
if (!destinationId) {
destinationId = await this.createDestination(queueArn);
}
const existing = await this.getSubscription(AmazonNotificationType.ORDER_CHANGE);
if (!existing) {
await this.createSubscription(destinationId, AmazonNotificationType.ORDER_CHANGE);
}
}
Check if the destination exists first (matching by ARN), then check if the subscription exists. Only create what’s missing. This Lambda runs every hour via EventBridge — if SP API drops the subscription (it has happened), it’s recreated automatically within the hour.
A note on migration: until recently SP API used ORDER_STATUS_CHANGE as the notification type. Amazon deprecated it in favor of ORDER_CHANGE, which includes more data and a different payload structure. The migration required updating both the subscription type and the payload parsing — a small change that took longer than expected because the two notification types are similar enough to look correct until they aren’t.
Multi-account: one Lambda, multiple sellers
Multiple brands means multiple seller accounts, each with its own SP API credentials. A single update-inventory Lambda processes SQS messages for all brands.
The solution is lazy in-memory caching:
const amazonServices = new Map<AmazonSellerAccount, AmazonInventoryService>();
for (const account of AMAZON_SELLER_ACCOUNTS) {
let service = amazonServices.get(account);
if (!service) {
const credentials = await secretsService.getCredentials(account);
service = new AmazonInventoryService(credentials, createRateLimiter(), logger);
amazonServices.set(account, service);
}
const { errors } = await service.updateInventory(products);
// errors collected as SQS batchItemFailures
}
Each account gets its own service instance (with its own cached credentials and its own rate limiter). Failed messages for one account go back to the queue without blocking the others — granular SQS batch item failure, not a whole-batch failure.
What I’d do differently
The external SKU cache has no TTL. Some SKUs are managed by an external service and must be excluded before fulfillment. The list is cached at module level with no TTL: it persists as long as the Lambda is warm. If the list changes, it stays stale until the container is recycled. I’d add a 15-minute TTL.
Feed polling uses a fixed interval. 10 seconds flat for 24 attempts. For large feeds, the first few attempts are almost certainly too early. Exponential backoff capped at 30 seconds would reduce unnecessary GET /feeds calls.
No circuit breaker on SP API. If Amazon is down, messages pile up in SQS until they hit the DLQ after N retries. A circuit breaker that stops processing when the failure rate exceeds a threshold would reduce noise and unnecessary authentication credential consumption.
Amazon SP API is workable in production, but it requires building infrastructure around it — the official client doesn’t handle token rotation, rate limiting, or polling. If you’re starting fresh, treat these as first-class architectural concerns, not afterthoughts.