Creating an FBA listing on Amazon has two parts an agent must not conflate:
- A catalog listing — the SKU exists in Amazon's catalog, has a title, images, price, and belongs to the seller. This is what buyers see.
- An FBA fulfillment channel — inventory for that SKU is stored in Amazon's fulfillment centers and shipped by Amazon, not the seller.
An MFN listing is a live buyable SKU that the seller ships themselves. An FBA listing is the same buyable SKU with its fulfillment channel switched to AMAZON_NA (or the regional equivalent) and inventory sent to Amazon. The SP-API path is: authenticate → create the listing MFN-style → patch fulfillment channel → send inbound shipment. This lesson covers the first three steps. Inbound shipments (the fulfillmentInbound_2024-03-20 API) are their own lesson.
Prerequisites
Before an agent can call SP-API on behalf of a seller, three artifacts must exist:
- A Selling Partner developer profile, registered under the seller's Amazon developer account, with the roles needed for the calls below (
Product Listing,Inventory and Order Tracking). - A refresh token — issued once when the seller authorized the app via the LWA consent flow (
https://sellercentral.amazon.com/apps/authorize/consent?application_id=...). This is durable; store it encrypted per seller. - LWA client credentials —
LWA_APP_IDandLWA_CLIENT_SECRETfrom the developer profile. These belong to the app, not the seller.
Regional facts an agent must not hard-code and forget:
| Region | Endpoint | Token endpoint | Example marketplace |
|---|---|---|---|
| North America | https://sellingpartnerapi-na.amazon.com | https://api.amazon.com/auth/o2/token | US = ATVPDKIKX0DER |
| Europe | https://sellingpartnerapi-eu.amazon.com | https://api.amazon.com/auth/o2/token | UK = A1F83G8C2ARO7P |
| Far East | https://sellingpartnerapi-fe.amazon.com | https://api.amazon.com/auth/o2/token | JP = A1VC38T7YXB528 |
Marketplace IDs are constants — cache them, don't look them up per request.
Step 1 — Exchange the refresh token for an access token
LWA access tokens live for one hour. Cache them per seller until roughly 5 minutes before expiry, then refresh. Never call the token endpoint on every SP-API request; you will get rate-limited and the seller will get billed for slow agents.
type LwaToken = { access_token: string; expires_at: number }
async function getAccessToken(sellerId: string): Promise<string> {
const cached = await tokenCache.get(sellerId)
if (cached && cached.expires_at > Date.now() + 5 * 60_000) {
return cached.access_token
}
const res = await fetch("https://api.amazon.com/auth/o2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: await getSellerRefreshToken(sellerId),
client_id: process.env.LWA_APP_ID!,
client_secret: process.env.LWA_CLIENT_SECRET!,
}),
})
if (!res.ok) {
throw new Error(`LWA token exchange failed: ${res.status} ${await res.text()}`)
}
const json = (await res.json()) as { access_token: string; expires_in: number }
const token: LwaToken = {
access_token: json.access_token,
expires_at: Date.now() + json.expires_in * 1000,
}
await tokenCache.set(sellerId, token)
return token.access_token
}
Every SP-API call from here on carries x-amz-access-token: <access_token>.
Step 2 — Create the listing (MFN by default)
Use the Listings Items API 2021-08-01, not the legacy JSON_LISTINGS_FEED via the Feeds API. The modern PUT endpoint is synchronous-per-SKU, returns a submissionId you can poll, and gives per-attribute error messages. The Feeds API is still fine for batch imports of 10,000+ SKUs but is overkill for a single new listing.
The body follows the product type definition for the chosen category. Get the schema for your product type first (call GET /definitions/2020-09-01/productTypes/{productType} — cache it, they change monthly, not daily) and shape your payload to match.
async function createListing(params: {
sellerId: string
sku: string
marketplaceId: string
productType: string // e.g. "LUGGAGE"
attributes: Record<string, unknown>
}) {
const token = await getAccessToken(params.sellerId)
const url = new URL(
`/listings/2021-08-01/items/${params.sellerId}/${encodeURIComponent(params.sku)}`,
"https://sellingpartnerapi-na.amazon.com",
)
url.searchParams.set("marketplaceIds", params.marketplaceId)
const res = await fetch(url, {
method: "PUT",
headers: {
"x-amz-access-token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({
productType: params.productType,
requirements: "LISTING",
attributes: params.attributes,
}),
})
if (!res.ok) {
throw new Error(`Create listing failed: ${res.status} ${await res.text()}`)
}
return res.json() as Promise<{ submissionId: string; status: string; issues?: unknown[] }>
}
A minimal attributes payload for a LUGGAGE product type in the US marketplace looks like:
{
"condition_type": [{ "value": "new_new", "marketplace_id": "ATVPDKIKX0DER" }],
"item_name": [{ "value": "24-inch Hardshell Rolling Suitcase",
"language_tag": "en_US", "marketplace_id": "ATVPDKIKX0DER" }],
"brand": [{ "value": "AcmeGear", "marketplace_id": "ATVPDKIKX0DER" }],
"externally_assigned_product_identifier": [
{ "type": "upc", "value": "812345678901", "marketplace_id": "ATVPDKIKX0DER" }
],
"purchasable_offer": [{
"currency": "USD",
"our_price": [{ "schedule": [{ "value_with_tax": 89.99 }] }],
"marketplace_id": "ATVPDKIKX0DER"
}]
}
Every value on the modern Listings API is an array of marketplace-scoped objects. This is not optional — a value without marketplace_id will validate but silently apply to none.
Step 3 — Switch fulfillment channel to FBA
Newly created listings default to Merchant Fulfilled (MFN). To promote the SKU to FBA, patch the fulfillment_availability attribute. This tells Amazon "inventory for this SKU is at fulfillment center X, ship it yourselves." The Buy Box will not switch to Amazon-fulfilled until inventory actually arrives at an FC, but the listing is now FBA-eligible.
async function switchToFba(params: {
sellerId: string
sku: string
marketplaceId: string
}) {
const token = await getAccessToken(params.sellerId)
const url = new URL(
`/listings/2021-08-01/items/${params.sellerId}/${encodeURIComponent(params.sku)}`,
"https://sellingpartnerapi-na.amazon.com",
)
url.searchParams.set("marketplaceIds", params.marketplaceId)
const res = await fetch(url, {
method: "PATCH",
headers: {
"x-amz-access-token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({
productType: "LUGGAGE",
patches: [{
op: "replace",
path: "/attributes/fulfillment_availability",
value: [{
fulfillment_channel_code: "AMAZON_NA",
marketplace_id: params.marketplaceId,
}],
}],
}),
})
if (!res.ok) {
throw new Error(`FBA switch failed: ${res.status} ${await res.text()}`)
}
return res.json() as Promise<{ submissionId: string; status: string }>
}
Interpreting the response
Every write returns a submissionId and a status. The three states an agent must handle:
ACCEPTED— the payload validated. The listing is live (or updated). Move on.INVALID— validation failed. Theissuesarray contains per-attribute messages withcode,message, andattributeNames. Re-read the product type schema, fix the payload, retry with the same SKU (idempotent).IN_PROGRESS— Amazon is still processing. PollGET /listings/2021-08-01/items/{sellerId}/{sku}?marketplaceIds={id}&includedData=issuesevery 30s. Don't poll faster; the write and read APIs share a rate bucket.
The common INVALID codes an agent will encounter first:
| Code | Cause | Fix |
|---|---|---|
90220 | Missing required attribute for product type | Read the product type schema, add the field |
88900 | GTIN not owned by seller or already used | Verify UPC ownership; try merchant_suggested_asin if catalog match exists |
4000004 | Marketplace not eligible for product type | Check productTypes availability in target marketplace |
18027 | Price outside marketplace min/max | Adjust our_price; some categories have Amazon-set floors |
What to build on top
Once these three calls work reliably for one SKU, the same shapes cover:
- Bulk creation — same PUT body, driven from a spreadsheet or agent-generated batch. Rate limit is ~5 requests per second per seller; use a queue.
- Inventory sync — the
fulfillment_availabilitypatch is where you also declarequantityfor MFN SKUs. FBA quantity is separately governed by inbound shipments and is read via the FBA Inventory API. - Multi-marketplace listings — the same SKU can list in multiple marketplaces by putting more entries in each attribute's array. One PUT, many storefronts.
Do not build custom retry logic on 429 responses without reading the x-amzn-RateLimit-Limit header. SP-API uses a token bucket per operation, and the header tells you the current bucket size — build your rate limiter around that number, not around a wall-clock delay.