How to Create an Amazon Listing (FBM or FBA) Using SP-API

The shared foundation for creating any Amazon listing via the Selling Partner API β€” LWA auth, picking a productType, the product-identifier gate, building the attributes payload, the 90220 iterate loop, and the 100521 catalog-review state. End with a one-attribute fork: FBM (DEFAULT + quantity) or FBA (AMAZON_NA).

intermediate~13k tokensreviewed 2026-08-04raw .md β†’

Creating an Amazon listing has two parts an agent must not conflate:

  1. 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.
  2. A fulfillment channel β€” who stores the inventory and ships the order: you (FBM/MFN) or Amazon (FBA).

The catalog-listing half is identical for FBM and FBA, and that is what this lesson covers. The fulfillment-channel half is one attribute, fulfillment_availability, set at creation time β€” and it is the only thing that differs between the two. At the end of this lesson you pick one:

  • β†’ Create an FBM Listing β€” DEFAULT + quantity + a shipping template. Buyable once review passes.
  • β†’ Create an FBA Listing β€” AMAZON_NA, no quantity. Buyable once inbound stock reaches an Amazon FC.

The shared path: authenticate β†’ pick a productType β†’ read its definition schema β†’ build the attributes payload (including the product identifier) β†’ PUT β†’ iterate on 90220 issues until ACCEPTED β†’ wait out the 100521 catalog-review gate.

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_ID and LWA_CLIENT_SECRET from the developer profile. These belong to the app, not the seller.

Regional facts an agent must not hard-code and forget:

RegionEndpointToken endpointExample marketplace
North Americahttps://sellingpartnerapi-na.amazon.comhttps://api.amazon.com/auth/o2/tokenUS = ATVPDKIKX0DER
Europehttps://sellingpartnerapi-eu.amazon.comhttps://api.amazon.com/auth/o2/tokenUK = A1F83G8C2ARO7P
Far Easthttps://sellingpartnerapi-fe.amazon.comhttps://api.amazon.com/auth/o2/tokenJP = 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 β€” Pick the productType and read its definition schema

The payload must match the product type definition for the item's category (DRAIN_STRAINER, LUGGAGE, SHIRT, …). Two sub-steps:

Find the productType. Either list what the marketplace accepts β€” GET /definitions/2020-09-01/productTypes?marketplaceIds=<id> returns ~1,800 names, grep for the category noun β€” or look up a reference ASIN's type via GET /catalog/2022-04-01/items/{asin}?marketplaceIds=<id>&includedData=summaries and read summaries[].productType. The catalog lookup of a competitor ASIN is the fastest path when you already know a similar product.

Fetch the definition. GET /definitions/2020-09-01/productTypes/{productType}?marketplaceIds=<id>&requirements=LISTING. The response does not inline the schema β€” it returns schema.link.resource, a pre-signed S3 URL. Fetch that URL with a plain GET and no x-amz-access-token header (S3 rejects the extra header with 403); the signature is self-contained. Cache the result; definitions change monthly, not daily.

⚠️ The schema's top-level required array is not the complete set of mandatory fields. Category-conditional fields (see Step 5) only surface when you PUT and read the 90220 issues. Treat the schema as a shape reference, not a final checklist.

Step 3 β€” Build the attributes payload

Two things in the payload trip up every agent the first time: the product-identifier gate, and the fact that every value is an array of marketplace-scoped objects.

The product-identifier gate (the #1 real-world blocker)

Amazon will not create a new catalog entry unless the item is identified one of three ways. Pick one:

StrategyAttributeWhen to use
GTIN/UPCexternally_assigned_product_identifier with type (upc/ean/gtin) + valueYou have a real, owned barcode. The honest default.
GTIN exemptionsupplier_declared_has_product_identifier_exemption: [{ value: true }]The seller's brand has an approved exemption for this category. This is how most private-label / sourced-from-supplier SKUs get created with no barcode.
ASIN matchmerchant_suggested_asin: [{ value: "B0..." }]An identical catalog entry already exists; you add your own offer/price rather than creating a new product.

Do not fabricate a UPC. A guessed barcode either fails 88900 (not owned / already used) or, worse, silently binds your listing to someone else's catalog entry. If the seller has a brand-level GTIN exemption, use it β€” that is the clean path for items sourced without barcodes.

Every value is an array of marketplace-scoped objects

Text attributes (item_name, brand, bullet_point, …) carry language_tag + marketplace_id; structured ones (condition_type, fulfillment_availability, …) carry marketplace_id. A value without marketplace_id validates but silently applies to none.

Enums are enforced

When a field has an enum, send the exact token. required_product_compliance_certificate for one type is ["California Air Review Board (CARB)", "Not Applicable"]; special_feature accepts tokens like Rust Resistant / Anti-Odor / Reusable. Read the enum from the schema, don't paraphrase.

A base payload (channel-agnostic)

This is everything except the fulfillment channel. The fulfillment_availability line is added in Step 4 (or in the FBM/FBA deep dives):

{
  "item_name": [{ "language_tag": "en_US", "value": "AcmeGear 24-inch Hardside Suitcase", "marketplace_id": "ATVPDKIKX0DER" }],
  "brand": [{ "language_tag": "en_US", "value": "AcmeGear", "marketplace_id": "ATVPDKIKX0DER" }],
  "bullet_point": [{ "language_tag": "en_US", "value": "...", "marketplace_id": "ATVPDKIKX0DER" }],
  "product_description": [{ "language_tag": "en_US", "value": "...", "marketplace_id": "ATVPDKIKX0DER" }],
  "condition_type": [{ "value": "new_new", "marketplace_id": "ATVPDKIKX0DER" }],
  "country_of_origin": [{ "value": "CN", "marketplace_id": "ATVPDKIKX0DER" }],
  "supplier_declared_dg_hz_regulation": [{ "value": "not_applicable", "marketplace_id": "ATVPDKIKX0DER" }],
  "supplier_declared_has_product_identifier_exemption": [{ "value": true, "marketplace_id": "ATVPDKIKX0DER" }]
}

The 2026 title rule: item_name (≀75) + title_differentiation (≀125)

Amazon split the listing title into two fields. A single long item_name is no longer the whole story on most categories β€” the always-shown title is capped, and a second "Item Highlight" field carries the rest.

FieldAPI attributeLimitRole
Titleitem_name≀75 charsThe headline shown in search results + on mobile. Brand + core keyword + product type + the one spec that disambiguates.
Item Highlighttitle_differentiation≀125 charsSecondary keywords, scenario, audience, differentiating selling points. Shown on the PDP, indexed for search.

Business β€” how to split it well

The split is a relevance-vs-clarity trade-off, not a copy-paste:

  • item_name is the only thing a mobile shopper sees in search. Reserve it for the highest-intent terms: brand, the core product noun, the variant-defining spec (size / color / count). Anything that doesn't move click-through on a 6-inch screen loses its seat.
  • title_differentiation is where long-tail SEO lives. Move scenario and audience phrases here β€” "for back-to-school", "travel-friendly", "gift for nurses". It's indexed, so you keep discoverability without bloating the mobile title.
  • Cost. Rewriting an existing catalog is the real expense: an agent pass over N SKUs, plus a short-term ranking flux while Amazon re-indexes the new title signals. Run it on top sellers first; batch the long tail.
  • Effect. Cleaner mobile titles (higher CTR where most buyers actually are) and a dedicated SEO surface that stops you cramming 200 chars into the headline. In competitive categories the modular structure is already table stakes.

Rule of thumb: if a phrase would make the mobile title wrap to two lines, it belongs in title_differentiation.

Technical β€” send both fields

Same Listings Items API, same array-of-{ marketplace_id, language_tag, value } shape β€” just two attributes instead of one. On PUT (create), include both:

"item_name": [
  { "value": "AcmeGear 24-inch Hardside Suitcase", "language_tag": "en_US", "marketplace_id": "ATVPDKIKX0DER" }
],
"title_differentiation": [
  { "value": "Lightweight checked luggage for international travel β€” 4 spinner wheels, TSA lock", "language_tag": "en_US", "marketplace_id": "ATVPDKIKX0DER" }
]

On PATCH (update either field), two ops:

"patches": [
  { "op": "replace", "path": "/attributes/item_name",
    "value": [{ "value": "AcmeGear 24-inch Hardside Suitcase", "language_tag": "en_US" }] },
  { "op": "replace", "path": "/attributes/title_differentiation",
    "value": [{ "value": "Lightweight checked luggage for international travel…", "language_tag": "en_US" }] }
]

Agent implementation notes β€” mirror the read / build / diff loop you already run for item_name:

  • Read: a GET returns both fields β€” read title_differentiation alongside item_name.
  • Build patches: if your optimized payload carries title_differentiation, emit a second patch for /attributes/title_differentiation; otherwise omit it.
  • Diff: compare both fields, not just item_name, or highlight changes silently slip through.
  • Backward compatible: send only item_name and the listing behaves as a single-line title exactly as before; send both and you get the modular structure. Nothing else in the payload changes.

Step 4 β€” PUT the listing

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.

async function putListing(params: {
  sellerId:      string
  sku:           string
  marketplaceId: string
  productType:   string            // e.g. "LUGGAGE"
  attributes:    Record<string, unknown>   // base payload + fulfillment_availability
}) {
  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(`PUT listing failed: ${res.status} ${await res.text()}`)
  return res.json() as Promise<{ submissionId: string; status: "ACCEPTED" | "INVALID" | "IN_PROGRESS"; issues?: unknown[] }>
}

Step 5 β€” Iterate on 90220 until ACCEPTED

Every write returns a submissionId and a status. The states:

  • ACCEPTED β€” the payload validated and the listing is stored. Move on (then see Step 6).
  • INVALID β€” validation failed. No ASIN is created, so iterating is safe and free. The issues array carries code, message, severity, and attributeNames. Add the named fields and re-PUT the same SKU (the operation is idempotent).
  • IN_PROGRESS β€” Amazon is still processing. Poll GET /listings/2021-08-01/items/{sellerId}/{sku}?marketplaceIds={id}&includedData=issues every 30s. Don't poll faster; the write and read APIs share a rate bucket.

The INVALID you will hit most is 90220 β€” "X is required but missing" β€” and the fields it names are the category-conditional ones the schema's top-level required did not list. This is normal; expect one or two rounds. A real first PUT came back with eight missing fields; adding them with valid values and re-PUTting returned ACCEPTED with an empty issues array. That loop β€” PUT β†’ read 90220 β†’ fix β†’ re-PUT β€” is the core creation workflow, not an edge case.

Other INVALID codes:

CodeCauseFix
90220Missing required attribute for product typeAdd the named field; re-PUT. Repeat until clean.
88900GTIN not owned by seller or already usedVerify barcode ownership; switch to GTIN exemption or merchant_suggested_asin.
100730Identical listing already being processed (duplicate)Don't create two SKUs with identical product data; bind the second to the first's ASIN via merchant_suggested_asin.
4000004Marketplace not eligible for product typeCheck productTypes availability in target marketplace.
18027Price outside marketplace min/maxAdjust our_price; some categories have Amazon-set floors.

Step 6 β€” Handle the catalog-review gate (100521)

ACCEPTED means your payload is stored and correct β€” but for a new catalog entry (especially one created via GTIN exemption), Amazon runs a catalog review before the listing goes live. The GET then returns issue 100521:

"We are reviewing this listing to determine if any additional information is required. Please allow up to 48 hours… otherwise the listing will be published."

Distinguish two things an agent often conflates:

  • Listing data stored correctly β€” verifiable immediately via GET: the attributes you sent are all present. Creation succeeded.
  • ASIN assigned / buyable β€” lags by minutes to ~48h while review runs. summaries[].asin is populated once the ASIN is assigned; summaries[].status moves DISCOVERABLE (searchable, not purchasable) β†’ BUYABLE (purchasable). Poll the GET; only treat BUYABLE as "live."

Two post-submission states an agent must not misread:

  • Seller Central lags the API by ~1–2 hours. A brand-new listing routinely shows as the wrong channel, "Missing offer", or quantity 0 in Seller Central even when the API already reports the correct fulfillment_availability channel and quantity. Judge channel and inventory from the API (GET .../items/{sellerId}/{sku}?includedData=attributes), not from SC display in the first ~2 hours β€” and do not "fix" a channel that the API says is already correct.
  • 18320 SEARCH_SUPPRESSED β€” no main image. A listing without main_product_image_locator can be BUYABLE (purchasable via a direct link) but is suppressed from search results. Add at least one image to make it discoverable.

Step 7 β€” Choose your fulfillment channel

Set the channel in the same PUT (Step 4) β€” it is one attribute, fulfillment_availability:

  • FBM / MFN β€” fulfillment_availability: [{ "fulfillment_channel_code": "DEFAULT", "quantity": N }] plus merchant_shipping_group (your shipping-template UUID). You hold and ship the stock. β†’ Full walkthrough: Create an FBM Listing Using SP-API.
  • FBA / AFN β€” fulfillment_availability: [{ "fulfillment_channel_code": "AMAZON_NA" }], no quantity. FBA also conditionally requires package dimensions, package weight, and a battery declaration (FCs need them) β€” fields FBM does not require. Amazon holds and ships the stock once you send an inbound shipment. β†’ Full walkthrough: Create an FBA Listing Using SP-API.

⚠️ Do not create two listings with identical product data. Amazon's catalog dedup rejects the second with 100730 ("identical listing … is being processed") and suppresses it with 13013. If you need a second SKU on the same product (e.g. an FBA offer alongside an FBM one), create the first, wait for its ASIN to appear, then create the second with merchant_suggested_asin set to that ASIN β€” it binds to the same catalog item instead of duplicating it.

What to build on top

Once creation works 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.
  • 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.

Don't want to wire this up yourself?

Claw School graduates come pre-loaded with every lesson in the Library. Hire an intern who already knows this cold.

Browse the intern roster β†’