Skip to main content

Etsy Product Sync - Complete Implementation Guide

Overview

Complete end-to-end implementation for syncing MedusaJS products to Etsy, including OAuth authentication, long-running workflows, and real-time status tracking.


Architecture Summary

Modules

  1. external_stores - Store provider implementations (Etsy, Shopify, etc.)
  2. etsysync - Sync orchestration and data models
  3. OAuth routes - Unified authentication for all platforms

Data Flow

Admin UI → OAuth Flow → Etsy Account Created

Admin UI → Select Products → Start Sync (returns transaction_id)

Admin UI → Confirm Sync → Workflow Proceeds

Background → Fetch Products → Map to Etsy Format → Create Listings

Database → Update Link Records → Update Sync Job

Admin UI → View Sync Status → See Etsy URLs

Setup Instructions

1. Environment Variables

Add to your .env file:

# Etsy OAuth Credentials (PKCE-based, no client secret needed)
ETSY_CLIENT_ID=your_etsy_keystring_from_app
ETSY_REDIRECT_URI=http://localhost:9000/admin/oauth/etsy/callback
ETSY_SCOPE=listings_r listings_w shops_r

# Optional: Production URLs
# ETSY_REDIRECT_URI=https://yourdomain.com/admin/oauth/etsy/callback

Note: Etsy API v3 uses PKCE (Proof Key for Code Exchange) instead of a client secret. The ETSY_CLIENT_SECRET is NOT required for OAuth - the system automatically generates PKCE code_verifier and code_challenge for each authorization request.

2. Database Migrations

The modules are already registered in medusa-config.ts. Run migrations:

yarn medusa db:migrate

This creates:

  • etsy_account table
  • etsy_sync_job table
  • Product-Etsy link table with extraColumns

3. Create Etsy App

  1. Go to https://www.etsy.com/developers/register (for new apps) or https://www.etsy.com/developers/your-apps (existing apps)
  2. Click "Create a New App"
  3. Fill in app details:
    • App Name: Your app name
    • Description: A description of your app
    • Who will be the users?: Choose appropriate option
    • Is your application commercial?: Select "no" for development
  4. Complete the captcha and click "Read Terms and Create App"
  5. IMPORTANT: Wait for app approval - Your API key is NOT active until approved!
    • Check status under "See API Key Details" in "Manage Your Apps"
    • Approval may take a few hours to a day
  6. Once approved, copy Keystring (Client ID) - this is your ETSY_CLIENT_ID
  7. Note: Etsy API v3 does NOT use a client secret for OAuth - it uses PKCE instead
  8. Add the Callback URL in your Etsy app settings:
    • Development: http://localhost:9000/admin/oauth/etsy/callback
    • Production: https://yourdomain.com/admin/oauth/etsy/callback
  9. Add to .env file:
    ETSY_CLIENT_ID=your_keystring_here
    ETSY_REDIRECT_URI=http://localhost:9000/admin/oauth/etsy/callback
    ETSY_SCOPE=listings_r listings_w shops_r

Common OAuth Errors

"The application that is requesting authorization is not recognized"

  • Your API key hasn't been approved yet - check status in Etsy Developer Dashboard
  • The client_id (keystring) is incorrect
  • Missing PKCE parameters (code_challenge, code_challenge_method) - this is handled automatically

"invalid_grant" during token exchange

  • Authorization code expired (codes are single-use and expire quickly)
  • code_verifier doesn't match the code_challenge
  • Restart the OAuth flow from the beginning

Usage Flow

Step 1: Connect Etsy Account

1.1 Create Etsy Account Record

# Via API or admin UI
POST /admin/etsy-accounts
{
"shop_name": "My Test Shop" # Optional, will be updated after OAuth
}

# Response: { id: "etsy_acc_123..." }

1.2 Initiate OAuth

GET /admin/oauth/etsy

# Response:
{
"location": "https://www.etsy.com/oauth/connect?...",
"state": "csrf_token_abc123"
}

Frontend should redirect user to the location URL.

1.3 User Authorizes on Etsy

User clicks "Allow access" on Etsy's authorization page.

1.4 Handle OAuth Callback

Etsy redirects to: http://localhost:9000/admin/oauth/etsy/callback?code=...&state=...

Frontend posts to callback endpoint:

POST /admin/oauth/etsy/callback
{
"id": "etsy_acc_123", # Account ID from step 1.1
"code": "authorization_code_from_url",
"state": "csrf_token_abc123"
}

# Response:
{
"success": true,
"account": { /* updated account with tokens */ },
"shop_info": {
"shop_id": "12345678",
"shop_name": "My Etsy Shop",
"shop_url": "https://www.etsy.com/shop/MyEtsyShop",
...
}
}

The account is now authenticated and ready to sync products!


Step 2: Sync Products to Etsy

2.1 Start Sync

POST /admin/products/etsy-sync
{
"product_ids": ["prod_123", "prod_456", "prod_789"],
"etsy_account_id": "etsy_acc_123"
}

# Response: 202 Accepted
{
"transaction_id": "wf_01ABCDEF...",
"summary": {
"total": 3
}
}

At this point:

  • Sync job created with status pending
  • Product-Etsy links created with status pending
  • Workflow is waiting for confirmation

2.2 Confirm Sync

Admin reviews the products and confirms:

POST /admin/products/etsy-sync/wf_01ABCDEF.../confirm

# Response: 200 OK
{
"success": true
}

Now the workflow proceeds in the background:

  1. Fetches product data (title, description, price, images, etc.)
  2. Validates each product
  3. Maps to Etsy listing format
  4. Creates listings on Etsy via API
  5. Uploads product images
  6. Updates link records with listing IDs and URLs
  7. Updates sync job with final counts

2.3 Check Sync Status

Query the sync job:

GET /admin/etsy-sync-jobs?transaction_id=wf_01ABCDEF...

# Response:
{
"id": "sync_job_123",
"transaction_id": "wf_01ABCDEF...",
"status": "completed", # or "processing", "failed"
"total_products": 3,
"synced_count": 3,
"failed_count": 0,
"error_log": {},
"started_at": "2025-01-14T12:00:00Z",
"completed_at": "2025-01-14T12:05:00Z"
}

Query product-etsy links:

# Via query.graph or custom endpoint
{
"product_id": "prod_123",
"etsy_account_id": "etsy_acc_123",
"sync_status": "synced",
"etsy_listing_id": "1234567890",
"etsy_url": "https://www.etsy.com/listing/1234567890",
"last_synced_at": "2025-01-14T12:05:00Z",
"sync_error": null
}

Product Data Mapping

MedusaJS → Etsy

The mapProductToEtsyListing function handles the conversion:

// MedusaJS Product
{
title: "Handmade Ceramic Mug",
description: "Beautiful handcrafted mug...",
variants: [{
prices: [{ amount: 2999 }], // $29.99 in cents
inventory_quantity: 10
}],
images: [
{ url: "https://cdn.example.com/mug1.jpg" },
{ url: "https://cdn.example.com/mug2.jpg" }
],
tags: [
{ value: "handmade" },
{ value: "ceramic" },
{ value: "mug" }
],
metadata: {
etsy_category_id: "1234" // Optional
}
}

// ↓ Mapped to ↓

// Etsy Listing
{
title: "Handmade Ceramic Mug",
description: "Beautiful handcrafted mug...",
price: 29.99, // Converted from cents
quantity: 10,
images: [
"https://cdn.example.com/mug1.jpg",
"https://cdn.example.com/mug2.jpg"
],
tags: ["handmade", "ceramic", "mug"],
category_id: "1234",
who_made: "i_did", // Required by Etsy
when_made: "made_to_order" // Required by Etsy
}

Validation Rules

Products must meet these requirements:

  • ✅ Title: 1-140 characters
  • ✅ At least one variant
  • ✅ Valid price > 0
  • ✅ Description (recommended)
  • ✅ Images (recommended)
  • ✅ Max 13 tags

Error Handling

Common Errors

1. OAuth Errors

Error: Failed to exchange code for token: invalid_grant

Cause: Authorization code expired or already used

Solution: Restart OAuth flow from step 1.2


2. Product Validation Errors

Error: Product title must be 140 characters or less

Cause: Product title too long for Etsy

Solution: Shorten product title or add custom mapping logic


3. API Rate Limits

Error: Too Many Requests

Cause: Exceeded Etsy API rate limits

Solution:

  • Implement rate limiting in batch sync
  • Add delays between requests
  • Sync in smaller batches

4. Token Expiration

Error: Unauthorized

Cause: Access token expired

Solution:

  • Implement automatic token refresh
  • Check token_expires_at before API calls
  • Use refreshAccessToken() method

Monitoring & Debugging

Check Sync Job Status

SELECT 
id,
transaction_id,
status,
total_products,
synced_count,
failed_count,
error_log,
completed_at
FROM etsy_sync_job
ORDER BY created_at DESC
LIMIT 10;
-- Via module link query
SELECT
product_id,
etsy_account_id,
sync_status,
etsy_listing_id,
etsy_url,
last_synced_at,
sync_error
FROM product_etsy_link
WHERE sync_status = 'failed';

View Workflow Logs

# Check MedusaJS logs for workflow execution
tail -f medusa.log | grep "etsy"

Advanced Features

Re-sync Products

To update existing listings:

  1. Check if product already has a link with etsy_listing_id
  2. If yes, call updateListing() instead of createListing()
  3. Update link with new sync timestamp
// In batch-sync-products.ts
const existingLink = await query.graph({
entity: "product_etsy_link",
filters: {
product_id,
etsy_account_id: input.etsy_account_id,
},
})

if (existingLink?.etsy_listing_id) {
// Update existing listing
await etsyProvider.updateListing(
account.access_token,
existingLink.etsy_listing_id,
listingData
)
} else {
// Create new listing
await etsyProvider.createListing(...)
}

Bulk Sync All Products

# Get all product IDs
GET /admin/products?limit=1000

# Extract IDs and sync
POST /admin/products/etsy-sync
{
"product_ids": ["prod_1", "prod_2", ..., "prod_1000"],
"etsy_account_id": "etsy_acc_123"
}

Scheduled Syncs

Use a cron job or scheduled workflow:

// Schedule daily sync at 2 AM
import { scheduleWorkflow } from "@medusajs/framework/workflows-sdk"

scheduleWorkflow({
workflow: syncProductsToEtsyWorkflow,
schedule: "0 2 * * *", // Cron expression
input: {
product_ids: await getProductsToSync(),
etsy_account_id: "etsy_acc_123",
},
})

Testing

Unit Tests

Test product mapping:

import { mapProductToEtsyListing, validateProductForEtsy } from "./map-product-to-etsy"

describe("mapProductToEtsyListing", () => {
it("should map product correctly", () => {
const product = {
title: "Test Product",
description: "Test description",
variants: [{
prices: [{ amount: 1999 }],
inventory_quantity: 5,
}],
images: [{ url: "https://example.com/image.jpg" }],
tags: [{ value: "test" }],
}

const listing = mapProductToEtsyListing(product)

expect(listing.title).toBe("Test Product")
expect(listing.price).toBe(19.99)
expect(listing.quantity).toBe(5)
})
})

Integration Tests

Test complete sync flow:

describe("Etsy Sync Integration", () => {
it("should sync product to Etsy", async () => {
// 1. Create test product
const product = await createTestProduct()

// 2. Create Etsy account (with test credentials)
const account = await createTestEtsyAccount()

// 3. Start sync
const { transaction } = await syncProductsToEtsyWorkflow(scope).run({
input: {
product_ids: [product.id],
etsy_account_id: account.id,
},
})

// 4. Confirm sync
await confirmSync(transaction.transactionId)

// 5. Wait for completion
await waitForWorkflowCompletion(transaction.transactionId)

// 6. Verify link created
const link = await getProductEtsyLink(product.id, account.id)
expect(link.sync_status).toBe("synced")
expect(link.etsy_listing_id).toBeTruthy()
})
})

Production Checklist

Before going live:

  • Set production Etsy app credentials
  • Update ETSY_REDIRECT_URI to production URL
  • Implement token auto-refresh
  • Add rate limiting to batch sync
  • Set up error monitoring (Sentry, etc.)
  • Create admin UI for managing Etsy accounts
  • Add product sync status to product list
  • Implement re-sync functionality
  • Add webhook handlers for Etsy events
  • Set up scheduled syncs (if needed)
  • Test with real Etsy shop
  • Document for your team

Troubleshooting

Sync Stuck in "Pending"

Cause: Workflow waiting for confirmation

Solution: Call the confirm endpoint


All Products Failing

Cause: Invalid Etsy credentials or expired token

Solution:

  1. Check etsy_account.access_token is not null
  2. Check token_expires_at hasn't passed
  3. Re-authenticate if needed

Images Not Uploading

Cause: Image URLs not accessible or invalid format

Solution:

  1. Ensure image URLs are publicly accessible
  2. Check image format (JPEG, PNG supported)
  3. Check image size limits (Etsy max 10MB)

Summary

Complete Implementation:

  • External stores module with Etsy provider
  • OAuth authentication flow
  • Long-running sync workflow with confirmation
  • Product data mapping and validation
  • Real Etsy API integration
  • Link-based status tracking
  • Error handling and logging

🎯 Ready for Production:

  • Add admin UI components
  • Implement token refresh
  • Add monitoring and alerts
  • Test with real Etsy shop
  • Deploy and monitor

The Etsy sync system is fully functional and ready to sync products from MedusaJS to Etsy!