Automate Etsy bulk listing creation and updates using `POST /v3/application/shops/{shop_id}/listings` (createDraftListing) and `PUT /shops/{shop_id}/listings/{listing_id}` (updateListing). Critical gotcha: six fields are mandatory on creation (`quantity`, `price`, `who_made`, `when_made`, `is_supply`, `taxonomy_id`) and prices use sub-units (`amount: 1099, divisor: 100` = $10.99). Both `Authorization: Bearer` and `x-api-key` headers are required on every request. Daily limit: 10,000 req/day.
API Quick Reference
OAuth 2.0 with PKCE
10 req/sec, 10,000 req/day
JSON
REST only
Understanding Etsy Listings API
The Etsy v3 Listings API allows programmatic creation, modification, and management of product listings. All listings start as drafts (`createDraftListing`) and must be activated via `updateListing` with `state: 'active'` to appear publicly. This two-step process lets you batch-create listings in draft state and review before activating.
The most error-prone aspect is Etsy's strict required field validation. Missing any of the six required fields returns a generic 400 error. The `taxonomy_id` is a numeric ID from Etsy's category tree — you must query `GET /seller-taxonomy/nodes` to find the correct ID for your product type. Currency values use Etsy's sub-unit format (`amount` integer + `divisor` integer) rather than decimal numbers.
For bulk operations, each listing requires at minimum one API call to create (plus additional calls for inventory updates), so 100 listings = 200+ API calls from your 10,000/day quota. Plan accordingly. See https://developers.etsy.com/documentation/reference#operation/createDraftListing for required fields.
https://api.etsy.com/v3/applicationSetting Up Etsy API Authentication for Listings
OAuth 2.0 with mandatory PKCE is required. Both the Bearer token and the x-api-key header must appear on every request. Access tokens expire in 1 hour — build automatic refresh logic into your API wrapper before making the first listing creation call.
- 1Register your app at https://www.etsy.com/developers/your-apps with 2FA enabled.
- 2Note your keystring (client ID) and client secret.
- 3Generate PKCE pair: code_verifier (43-128 random chars), code_challenge = base64url(SHA256(verifier)).
- 4Authorize with scopes: listings_r listings_w
- 5Exchange auth code for access_token and refresh_token using code_verifier.
- 6Store refresh_token securely — it lasts 90 days.
- 7Implement auto-refresh: call token endpoint with grant_type=refresh_token when access_token is within 60s of expiry.
1import os, time, requests23KEYSTRING = os.environ['ETSY_KEYSTRING']4_cache = {'token': None, 'exp': 0, 'refresh': os.environ['ETSY_REFRESH_TOKEN']}56def get_token():7 if time.time() < _cache['exp'] - 60:8 return _cache['token']9 r = requests.post('https://api.etsy.com/v3/public/oauth/token',10 data={'grant_type': 'refresh_token', 'client_id': KEYSTRING, 'refresh_token': _cache['refresh']})11 r.raise_for_status()12 d = r.json()13 _cache.update({'token': d['access_token'], 'exp': time.time() + d['expires_in']})14 return d['access_token']1516def etsy(method, path, **kwargs):17 headers = {'Authorization': f'Bearer {get_token()}', 'x-api-key': KEYSTRING}18 resp = getattr(requests, method)(f'https://api.etsy.com/v3/application{path}', headers=headers, **kwargs)19 resp.raise_for_status()20 return resp.json()Security notes
- •Store ETSY_REFRESH_TOKEN and ETSY_KEYSTRING in environment variables — never hardcode.
- •Always include x-api-key on every request alongside Authorization: Bearer.
- •Listings created via API start as drafts — review before activating to prevent accidentally publishing incomplete listings.
- •Refresh tokens last 90 days — alert when within 7 days of expiry and prompt for re-authorization.
- •Never log raw price amounts without context — they are sub-units and will appear 100x inflated.
Key endpoints
/shops/{shop_id}/listingscreateDraftListing — creates a new draft listing. All six required fields must be present or the request returns 400. Listing starts inactive.
| Parameter | Type | Required | Description |
|---|---|---|---|
quantity | number | required | Available stock quantity |
title | string | required | Listing title (max 140 chars) |
price | object | required | {amount: integer, divisor: integer} — e.g., {amount: 2999, divisor: 100} = $29.99 |
who_made | string | required | i_did | collective | someone_else |
when_made | string | required | made_to_order | 2020_2024 | 2010_2019 | etc. |
taxonomy_id | number | required | Numeric category ID from /seller-taxonomy/nodes |
is_supply | boolean | required | True if this is a craft supply, false for finished goods |
Request
1{"quantity": 10, "title": "Handmade Silver Ring", "description": "Sterling silver ring, made to order", "price": {"amount": 2999, "divisor": 100}, "who_made": "i_did", "when_made": "made_to_order", "taxonomy_id": 68, "is_supply": false, "shipping_profile_id": 12345678, "tags": ["silver", "ring", "handmade"]}Response
1{"listing_id": 99887766, "state": "draft", "title": "Handmade Silver Ring", "price": {"amount": 2999, "divisor": 100, "currency_code": "USD"}, "quantity": 10, "taxonomy_id": 68}/shops/{shop_id}/listings/{listing_id}updateListing — modify an existing listing's fields or change state from draft to active.
| Parameter | Type | Required | Description |
|---|---|---|---|
state | string | optional | active | inactive | draft |
title | string | optional | Updated title |
price | object | optional | Updated price in sub-unit format |
Request
1{"state": "active", "title": "Updated Title", "price": {"amount": 3499, "divisor": 100}}Response
1{"listing_id": 99887766, "state": "active", "title": "Updated Title", "price": {"amount": 3499, "divisor": 100, "currency_code": "USD"}}/shops/{shop_id}/listingsGet all listings for a shop. Use state parameter to filter by draft, active, or inactive.
| Parameter | Type | Required | Description |
|---|---|---|---|
state | string | optional | active | inactive | draft | expired | sold_out |
limit | number | optional | Results per page (max 100) |
offset | number | optional | Pagination offset (max 50,000) |
Request
1GET https://api.etsy.com/v3/application/shops/12345678/listings?state=draft&limit=100Response
1{"count": 3, "results": [{"listing_id": 99887766, "state": "draft", "title": "Ring", "price": {"amount": 2999, "divisor": 100}}]}/listings/{listing_id}/inventoryupdateListingInventory — set stock quantities and pricing per product variation (offerings).
| Parameter | Type | Required | Description |
|---|---|---|---|
products | array | required | Array of variation products with property_values and offerings |
Request
1{"products": [{"property_values": [{"property_id": 200, "property_name": "Size", "values": ["S"]}], "offerings": [{"price": {"amount": 2999, "divisor": 100}, "quantity": 5, "is_enabled": true}]}]}Response
1{"listing_id": 99887766, "products": [{"product_id": 12345, "offerings": [{"offering_id": 67890, "quantity": 5, "is_enabled": true}]}]}Step-by-step automation
Look Up Taxonomy ID for Your Product Category
Why: taxonomy_id is a required field on createDraftListing and must match a valid category from Etsy's seller taxonomy — guessing causes 400 errors.
Query `GET /seller-taxonomy/nodes` to browse Etsy's category tree. The response is a hierarchical structure of categories. Find the lowest-level node that matches your product type and note its `id`. For example, jewelry rings = taxonomy_id 68.
1# Fetch all seller taxonomy categories2curl -X GET 'https://api.etsy.com/v3/application/seller-taxonomy/nodes' \3 -H 'x-api-key: your_keystring'Pro tip: Cache the taxonomy tree — it rarely changes. Fetching it on every import run wastes daily quota. Store it in a JSON file and refresh monthly.
Expected result: A list of taxonomy nodes matching your keyword with their numeric IDs. Use the most specific (lowest-level) match for best category placement.
Create Draft Listings from Spreadsheet/Feed
Why: All required fields must be present — building a validated listing object before the API call prevents wasted quota on 400 errors.
Read your product spreadsheet/CSV, validate that all required fields are present and correctly formatted (especially prices in sub-units), then call `createDraftListing` for each product. Add 150ms delays between calls to stay well under the 10 req/sec limit.
1curl -X POST 'https://api.etsy.com/v3/application/shops/12345678/listings' \2 -H 'Authorization: Bearer your_access_token' \3 -H 'x-api-key: your_keystring' \4 -H 'Content-Type: application/json' \5 -d '{6 "quantity": 10,7 "title": "Handmade Silver Ring",8 "description": "Sterling silver ring, made to order in your size.",9 "price": {"amount": 2999, "divisor": 100},10 "who_made": "i_did",11 "when_made": "made_to_order",12 "taxonomy_id": 68,13 "is_supply": false,14 "shipping_profile_id": 12345678,15 "tags": ["silver ring", "handmade jewelry", "sterling silver"]16 }'Pro tip: Always log the returned listing_id alongside the source row — you'll need it for the activation and inventory update steps, and reprocessing failures without IDs means you'll create duplicates.
Expected result: Draft listings created for each CSV row. Each listing_id is returned and stored for the activation step. Failed rows are logged with error details.
Activate Draft Listings
Why: Draft listings are not visible to buyers — the activation step publishes them to your Etsy shop.
After reviewing draft listings in the Etsy seller dashboard, batch-activate them by calling `updateListing` with `state: 'active'` for each draft listing ID collected in Step 2.
1curl -X PUT 'https://api.etsy.com/v3/application/shops/12345678/listings/99887766' \2 -H 'Authorization: Bearer your_access_token' \3 -H 'x-api-key: your_keystring' \4 -H 'Content-Type: application/json' \5 -d '{"state": "active"}'Pro tip: Consider adding a human review step between creation and activation — batch-creating 100 listings and immediately activating them makes mistakes harder to catch before buyers see them.
Expected result: Listings transition from draft to active state and become visible on your Etsy shop.
Update Existing Listing Prices and Titles in Bulk
Why: Seasonal price updates and title optimizations are the most common ongoing listing maintenance tasks for high-volume Etsy sellers.
For batch price updates, read current listings via `GET /shops/{shop_id}/listings`, calculate new prices based on your rules (e.g., +10% for summer pricing), and call `updateListing` for each changed listing. Track only those with price changes to minimize API calls.
1# Update price and title in one call2curl -X PUT 'https://api.etsy.com/v3/application/shops/12345678/listings/99887766' \3 -H 'Authorization: Bearer your_access_token' \4 -H 'x-api-key: your_keystring' \5 -H 'Content-Type: application/json' \6 -d '{"price": {"amount": 3499, "divisor": 100}, "title": "Handmade Silver Ring - Updated"}'Pro tip: Etsy's offset pagination caps at 50,000 — shops with more than 50,000 listings need to break queries into date windows or use listing ID ranges to access all listings.
Expected result: All active listings with changed prices are updated. Listings already at the target price are skipped to minimize API calls.
Complete working code
Complete pipeline that reads a CSV product feed, fetches the taxonomy tree once, creates draft listings with full validation, and activates them after a configurable delay.
1import os, csv, time, json, logging, requests23logging.basicConfig(level=logging.INFO)4KEYSTRING = os.environ['ETSY_KEYSTRING']5SHOP_ID = os.environ['ETSY_SHOP_ID']6_cache = {'token': None, 'exp': 0, 'refresh': os.environ['ETSY_REFRESH_TOKEN']}78def get_token():9 if time.time() < _cache['exp'] - 60: return _cache['token']10 r = requests.post('https://api.etsy.com/v3/public/oauth/token',11 data={'grant_type': 'refresh_token', 'client_id': KEYSTRING, 'refresh_token': _cache['refresh']})12 r.raise_for_status(); d = r.json()13 _cache.update({'token': d['access_token'], 'exp': time.time() + d['expires_in']})14 return d['access_token']1516def etsy(method, path, **kwargs):17 resp = getattr(requests, method)(f'https://api.etsy.com/v3/application{path}',18 headers={'Authorization': f'Bearer {get_token()}', 'x-api-key': KEYSTRING}, **kwargs)19 resp.raise_for_status(); return resp.json()2021def price(usd): return {'amount': round(float(usd) * 100), 'divisor': 100}2223def create_draft(shop_id, row):24 return etsy('post', f'/shops/{shop_id}/listings', json={25 'quantity': int(row['quantity']), 'title': row['title'], 'description': row['description'],26 'price': price(row['price_usd']), 'who_made': row['who_made'], 'when_made': row['when_made'],27 'taxonomy_id': int(row['taxonomy_id']), 'is_supply': row.get('is_supply','false').lower()=='true',28 'tags': [t.strip() for t in row.get('tags','').split(',') if t.strip()][:13]29 })3031def activate(shop_id, listing_id):32 return etsy('put', f'/shops/{shop_id}/listings/{listing_id}', json={'state': 'active'})3334def main():35 created_ids = []36 with open('products.csv') as f:37 for row in csv.DictReader(f):38 try:39 result = create_draft(SHOP_ID, row)40 created_ids.append(result['listing_id'])41 logging.info(f'Draft created: {result["listing_id"]} - {row["title"]}')42 except Exception as e:43 logging.error(f'Failed: {row.get("title")} — {e}')44 time.sleep(0.2)45 46 # Save draft IDs before activation (review period)47 with open('draft_ids.json', 'w') as f:48 json.dump(created_ids, f)49 logging.info(f'Created {len(created_ids)} drafts. Review in Etsy dashboard, then run activation.')50 51 # Activate all (run this after review)52 for lid in created_ids:53 try:54 activate(SHOP_ID, lid)55 logging.info(f'Activated {lid}')56 except Exception as e:57 logging.error(f'Activation failed {lid}: {e}')58 time.sleep(0.2)5960if __name__ == '__main__': main()Error handling
Bad Request — missing required field: taxonomy_idOne of the six required fields (quantity, price, who_made, when_made, is_supply, taxonomy_id) is missing or invalid. The error message identifies which field.
Validate all six required fields before making the API call. Use the `find_taxonomy_id()` helper to look up valid taxonomy IDs. Ensure `who_made` is one of: `i_did`, `collective`, `someone_else`.
No retry until payload is corrected. Log the failed row data for manual review.
price amount must be a positive integerPassing a decimal value (e.g., `price: 29.99`) instead of Etsy's sub-unit format (`amount: 2999, divisor: 100`).
Convert all prices: `amount = round(price_usd * 100)`, `divisor = 100`. Never pass floats directly as `amount`.
Fix price format and retry.
Rate limit exceededExceeded 10 req/sec or 10,000 req/day. Bulk listing creation (100 listings × 2 calls each = 200 calls) can consume 2% of the daily quota.
Add 150ms delays between requests. Plan large imports to avoid exhausting the daily 10,000 quota — spread across multiple days if needed.
Exponential backoff starting at 200ms. Check Etsy rate-limit response headers.
UnauthorizedMissing x-api-key header, expired access token, or both.
Ensure both headers are present: `Authorization: Bearer <token>` AND `x-api-key: <keystring>`. Implement automatic token refresh.
Refresh token and retry once.
Forbidden — listings_w scope requiredOAuth token was issued without the listings_w scope.
Re-authorize the user including listings_w in the scope parameter.
No retry until re-authorized with correct scope.
Rate Limits for Etsy Listings API
| Scope | Limit | Window |
|---|---|---|
| Requests per second | 10 req/sec | New apps: 5 req/sec |
| Requests per day | 10,000 req/day | Sliding 24-hour window |
| Access token | 3,600 seconds | Per token |
1import time23def etsy_with_retry(method, path, max_retries=4, **kwargs):4 delay = 0.25 for attempt in range(max_retries):6 resp = getattr(requests, method)(f'https://api.etsy.com/v3/application{path}',7 headers={'Authorization': f'Bearer {get_token()}', 'x-api-key': KEYSTRING}, **kwargs)8 if resp.status_code == 429:9 print(f'Rate limited, waiting {delay}s')10 time.sleep(delay); delay = min(delay * 2, 10); continue11 if resp.status_code == 401:12 _cache['exp'] = 0 # Force token refresh13 continue14 resp.raise_for_status()15 return resp.json()16 raise RuntimeError('Retries exhausted')- Add 150ms between each API call in bulk operations — safe buffer under the 10 req/sec limit.
- Cache the taxonomy tree locally — it rarely changes and calling it on every import wastes quota.
- For 100+ listings, spread creation across multiple days to avoid consuming the full daily 10,000 quota.
- Create drafts first, review in Etsy dashboard, then activate — avoids wasting activation calls on listings that need edits.
- Monitor daily quota consumption — 200 calls (100 listings) = 2% of daily limit, but large shops can hit the cap.
Security checklist
- Store ETSY_KEYSTRING and ETSY_REFRESH_TOKEN in environment variables.
- Both Authorization: Bearer and x-api-key headers must be on every request — build into API wrapper.
- Never log raw price amount values without context — they appear 100x inflated without divisor.
- Listings created via API are drafts by default — review before activating to prevent publishing incomplete products.
- Refresh tokens expire in 90 days — set calendar reminders to re-authorize before expiry.
- Apps inactive for 6 months are banned by Etsy — ensure your automation makes at least one API call per month.
Automation use cases
Seasonal Catalog Import
intermediateImport 50-200 new product listings from a spreadsheet each season with consistent pricing and tagging.
Print-on-Demand Sync
intermediateAuto-create Etsy listings when new designs are approved in your POD platform, pulling title, description, and pricing from a feed.
Price Update Campaign
beginnerApply a percentage markup to all active listings for holiday seasons, then revert after the event.
Multichannel Listing Sync
advancedMirror product catalog from Shopify to Etsy, creating draft listings for each product and managing inventory separately.
No-code alternatives
Don't want to write code? These platforms can automate the same workflows visually.
Zapier
Free tier available; Starter $19.99/monthZapier's Etsy integration can create listings triggered by Google Sheets rows or other data sources.
- + Zero code
- + Easy spreadsheet integration
- + Good for simple single-listing creation
- - Limited required field support
- - No bulk mode
- - Can't handle price sub-unit conversion
Make (formerly Integromat)
Free tier (1,000 ops/month); Core $9/monthMake can iterate over a Google Sheets table and create Etsy draft listings via the HTTP module with proper header support.
- + Handles loops for bulk creation
- + HTTP module for custom headers
- + More affordable
- - Must manually handle PKCE auth token refresh
- - Complex setup for required fields
- - No taxonomy lookup helper
n8n
Self-hosted free; Cloud Starter €20/monthn8n with HTTP Request nodes can create Etsy listings from any data source with full control over headers and payload.
- + Self-hosted free
- + Full control over headers including x-api-key
- + Loop support
- - Must implement PKCE OAuth manually
- - No native Etsy node
- - Requires technical setup
Best practices
- Always validate the six required fields (quantity, price, who_made, when_made, is_supply, taxonomy_id) before calling createDraftListing to avoid wasting quota on 400 errors.
- Use Etsy's sub-unit price format correctly: convert dollars to cents (`amount = round(usd * 100), divisor = 100`) — never pass decimal prices.
- Look up taxonomy_id once and cache it — querying the taxonomy tree on every import wastes API calls.
- Create all listings as drafts first, review in the Etsy dashboard, then activate in bulk — this prevents publishing listings with errors.
- Add 150ms delays between bulk listing API calls to stay safely under the 10 req/sec limit.
- Tags are limited to 13 per listing — prioritize highest-search-volume keywords.
- For print-on-demand sellers, the listing description needs to match Etsy's handmade policies — review before bulk activation.
- Monitor your 10,000/day quota when doing large imports — 100 listings ≈ 200 API calls (create + inventory update per listing).
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building an Etsy bulk listing creation tool using the v3 API. I'm hitting 400 errors on createDraftListing. Here's my payload: [paste payload]. The error response is: [paste response]. I'm using OAuth 2.0 with PKCE and including both Authorization: Bearer and x-api-key headers. Help me identify which required field is missing or invalid, and verify my price format is using the correct sub-unit structure.
Build an Etsy listing manager dashboard that shows all shop listings fetched from the Etsy API (via Supabase Edge Function), allows filtering by state (draft/active/inactive), displays prices formatted correctly (amount/divisor), and has bulk-action buttons for activate/deactivate/price-update. Include a CSV import feature that validates the six required fields before submission. Store listing data in Supabase. Use shadcn/ui and Tailwind CSS.
Frequently asked questions
What are all six required fields for createDraftListing?
The six required fields are: (1) `quantity` (integer, available stock), (2) `price` (object with `amount` in sub-units and `divisor`, e.g., `{amount: 2999, divisor: 100}` for $29.99), (3) `who_made` (one of: `i_did`, `collective`, `someone_else`), (4) `when_made` (e.g., `made_to_order`, `2020_2024`, `2010_2019`), (5) `is_supply` (boolean, true for craft supplies), (6) `taxonomy_id` (numeric ID from /seller-taxonomy/nodes). Missing any one returns 400.
How do I find the correct taxonomy_id for my product category?
Call `GET https://api.etsy.com/v3/application/seller-taxonomy/nodes` with only your `x-api-key` header (no auth required). This returns Etsy's full category hierarchy. Search for your product type and use the lowest-level (most specific) node's `id` value. For jewelry rings, taxonomy_id is typically 68. Cache this response — the taxonomy changes rarely.
Why does Etsy use sub-unit prices instead of decimal values?
Etsy uses integer sub-units to avoid floating-point precision issues in financial calculations. The `amount` is the price in the smallest currency denomination (e.g., cents for USD), and `divisor` is how many sub-units make one unit (always 100 for USD/EUR/GBP). So $29.99 = `{amount: 2999, divisor: 100}`. Always use `amount / divisor` to display prices — never show the raw `amount`.
What happens to listing fees when I create listings via API?
The same Etsy listing fees apply regardless of how the listing is created (API vs manual): $0.20 per listing for 4 months, plus transaction and payment processing fees. Creating draft listings does NOT incur the listing fee — you're only charged when the listing is activated (state changes to active). So you can create and edit drafts for free.
What happens when I hit the rate limit?
Etsy returns HTTP 429. The daily limit of 10,000 requests uses a sliding 24-hour window — it doesn't fully reset at midnight. If you exhaust your quota, you'll need to wait up to 24 hours for it to replenish. Implement 150ms delays between requests, and consider spreading large imports over multiple days. For higher limits, email developers@etsy.com after your app is operational.
Is the Etsy API free?
The Etsy API is free to use — there are no API usage fees. Personal access is free for up to 5 shops you own or that grant you access. Commercial access (for building tools that serve multiple sellers) is also free but requires manual approval from Etsy. Your normal Etsy seller fees (listing fees, transaction fees) still apply when you activate listings.
Can RapidDev help build a custom Etsy listing management tool?
Yes. RapidDev has built 600+ apps including Etsy seller tools with bulk listing creation, CSV import pipelines, and print-on-demand integrations. We handle the full PKCE OAuth implementation, the taxonomy lookup system, and price format conversion correctly. Book a free consultation at rapidevelopers.com.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation