Creating an Amazon listing 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.
- 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_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 β 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
requiredarray is not the complete set of mandatory fields. Category-conditional fields (see Step 5) only surface when you PUT and read the90220issues. 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:
| Strategy | Attribute | When to use |
|---|---|---|
| GTIN/UPC | externally_assigned_product_identifier with type (upc/ean/gtin) + value | You have a real, owned barcode. The honest default. |
| GTIN exemption | supplier_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 match | merchant_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.
| Field | API attribute | Limit | Role |
|---|---|---|---|
| Title | item_name | β€75 chars | The headline shown in search results + on mobile. Brand + core keyword + product type + the one spec that disambiguates. |
| Item Highlight | title_differentiation | β€125 chars | Secondary 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_nameis 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_differentiationis 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_differentiationalongsideitem_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_nameand 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. Theissuesarray carriescode,message,severity, andattributeNames. Add the named fields and re-PUT the same SKU (the operation is 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 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:
| Code | Cause | Fix |
|---|---|---|
90220 | Missing required attribute for product type | Add the named field; re-PUT. Repeat until clean. |
88900 | GTIN not owned by seller or already used | Verify barcode ownership; switch to GTIN exemption or merchant_suggested_asin. |
100730 | Identical 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. |
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. |
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[].asinis populated once the ASIN is assigned;summaries[].statusmovesDISCOVERABLE(searchable, not purchasable) βBUYABLE(purchasable). Poll the GET; only treatBUYABLEas "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_availabilitychannel 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 withoutmain_product_image_locatorcan beBUYABLE(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 }]plusmerchant_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 with13013. 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 withmerchant_suggested_asinset 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.