{
  "name": "Track competitor listing prices on Etsy",
  "nodes": [
    {
      "id": "manual-trigger",
      "name": "Run tracker manually",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        0,
        300
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "schedule-trigger",
      "name": "Check every 6 hours",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        0,
        500
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 6
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "configuration",
      "name": "Configuration (EDIT ME)",
      "type": "n8n-nodes-base.set",
      "position": [
        280,
        400
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "config-listing-ids",
              "name": "listing_ids",
              "type": "string",
              "value": "PASTE_COMMA_SEPARATED_LISTING_IDS_HERE"
            },
            {
              "id": "config-threshold",
              "name": "minimum_change_percent",
              "type": "number",
              "value": 0
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "build-request",
      "name": "Build Etsy batch request",
      "type": "n8n-nodes-base.code",
      "position": [
        560,
        400
      ],
      "parameters": {
        "jsCode": "// Validates the user input and builds one public Etsy API URL.\n// Listing IDs are the numeric values found in Etsy listing URLs.\nconst config = $input.first().json;\nconst rawIds = String(config.listing_ids || '');\nconst listingIds = [...new Set(rawIds.match(/\\d+/g) || [])];\n\nif (listingIds.length === 0) {\n  throw new Error('Add at least one numeric Etsy listing ID in Configuration (EDIT ME).');\n}\nif (listingIds.length > 100) {\n  throw new Error('Etsy accepts up to 100 listing IDs per batch request.');\n}\n\nconst minimumChangePercent = Number(config.minimum_change_percent || 0);\nif (!Number.isFinite(minimumChangePercent) || minimumChangePercent < 0) {\n  throw new Error('minimum_change_percent must be zero or a positive number.');\n}\n\nconst encodedIds = encodeURIComponent(listingIds.join(','));\n\nreturn [{\n  json: {\n    listing_ids: listingIds,\n    minimum_change_percent: minimumChangePercent,\n    request_url: `https://api.etsy.com/v3/application/listings/batch?listing_ids=${encodedIds}`\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "fetch-listings",
      "name": "Fetch public Etsy listings",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        840,
        400
      ],
      "parameters": {
        "url": "={{ $json.request_url }}",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "typeVersion": 4.3
    },
    {
      "id": "compare-prices",
      "name": "Compare prices with previous run",
      "type": "n8n-nodes-base.code",
      "position": [
        1120,
        400
      ],
      "parameters": {
        "jsCode": "// Compares the current Etsy prices with the previous production run.\n// Workflow static data keeps the snapshot inside n8n, so no database is required.\nconst response = $input.first().json;\nconst config = $('Build Etsy batch request').first().json;\nconst listings = Array.isArray(response.results) ? response.results : [];\nconst requestedIds = config.listing_ids.map(String);\nconst threshold = Number(config.minimum_change_percent || 0);\nconst staticData = $getWorkflowStaticData('global');\nconst previous = staticData.etsyPriceTrackerSnapshot || {};\nconst hasPreviousSnapshot = Object.keys(previous).length > 0;\nconst current = {};\nconst changes = [];\n\nconst getPrice = (listing) => {\n  const amount = Number(listing?.price?.amount);\n  const divisor = Number(listing?.price?.divisor || 100);\n  return Number.isFinite(amount) && divisor > 0 ? amount / divisor : null;\n};\n\nfor (const listing of listings) {\n  const id = String(listing.listing_id);\n  const price = getPrice(listing);\n  const record = {\n    listing_id: id,\n    title: listing.title || '',\n    shop_id: listing.shop_id ? String(listing.shop_id) : '',\n    price,\n    currency: listing?.price?.currency_code || '',\n    quantity: listing.quantity ?? null,\n    state: listing.state || '',\n    url: listing.url || `https://www.etsy.com/listing/${id}`,\n    checked_at: new Date().toISOString()\n  };\n  current[id] = record;\n\n  const old = previous[id];\n  if (!hasPreviousSnapshot || !old || price === null || old.price === null) continue;\n\n  const oldCents = Math.round(Number(old.price) * 100);\n  const newCents = Math.round(price * 100);\n  if (oldCents === newCents) continue;\n\n  const changePercent = oldCents === 0\n    ? null\n    : Math.round((((newCents - oldCents) / oldCents) * 100) * 100) / 100;\n  if (changePercent !== null && Math.abs(changePercent) < threshold) continue;\n\n  changes.push({\n    type: 'price_changed',\n    listing_id: id,\n    title: record.title,\n    old_price: old.price,\n    new_price: price,\n    currency: record.currency,\n    change_percent: changePercent,\n    url: record.url\n  });\n}\n\nif (hasPreviousSnapshot) {\n  for (const id of requestedIds) {\n    if (previous[id] && !current[id]) {\n      changes.push({\n        type: 'listing_unavailable',\n        listing_id: id,\n        title: previous[id].title || '',\n        old_price: previous[id].price,\n        currency: previous[id].currency || '',\n        url: previous[id].url || `https://www.etsy.com/listing/${id}`\n      });\n    }\n  }\n}\n\nstaticData.etsyPriceTrackerSnapshot = current;\n\nconst unavailableOnFirstRun = hasPreviousSnapshot\n  ? []\n  : requestedIds.filter((id) => !current[id]);\n\nreturn [{\n  json: {\n    status: hasPreviousSnapshot ? 'comparison_complete' : 'baseline_created',\n    checked_at: new Date().toISOString(),\n    requested_count: requestedIds.length,\n    found_count: listings.length,\n    total_changes: changes.length,\n    changes,\n    unavailable_on_first_run: unavailableOnFirstRun,\n    current_listings: Object.values(current)\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "changes-found",
      "name": "Changes found?",
      "type": "n8n-nodes-base.if",
      "position": [
        1400,
        400
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-price-changes",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.total_changes }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "price-change-alerts",
      "name": "Price change alerts",
      "type": "n8n-nodes-base.set",
      "position": [
        1680,
        300
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "alert-message",
              "name": "result_message",
              "type": "string",
              "value": "=Price changes detected for {{ $json.total_changes }} Etsy listing(s). Connect this output to Slack, email, Telegram, or your preferred notification node."
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "no-alerts-summary",
      "name": "Baseline or no changes",
      "type": "n8n-nodes-base.set",
      "position": [
        1680,
        500
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "summary-message",
              "name": "result_message",
              "type": "string",
              "value": "={{ $json.status === 'baseline_created' ? 'Baseline saved. The next scheduled run will compare prices against it.' : 'No price changes matched your threshold.' }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "overview-sticky",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -40,
        -420
      ],
      "parameters": {
        "color": 1,
        "width": 660,
        "height": 640,
        "content": "## Track Competitor Listing Prices on Etsy\n\nMonitor the current prices of selected public Etsy listings and detect changes without OAuth, a database, or a third-party scraping service. This workflow uses Etsy's official Open API and stores the previous snapshot in n8n workflow static data.\n\n### How it works\n- A manual or scheduled trigger starts the price check.\n- **Configuration (EDIT ME)** accepts one or more numeric Etsy listing IDs and an optional minimum percentage change.\n- One public batch request fetches the current listing details.\n- The workflow normalizes Etsy Money values, compares them with the previous production run, and reports price changes or unavailable listings.\n- The first production run creates the baseline; later runs perform comparisons.\n\n### Setup\n1. Create an Etsy developer app and copy its keystring and shared secret.\n2. In **Fetch public Etsy listings**, create a Header Auth credential. Set the header name to `x-api-key` and the value to `KEYSTRING:SHARED_SECRET`.\n3. Add competitor listing IDs in **Configuration (EDIT ME)**. You can find each numeric ID in its Etsy URL.\n4. Activate the workflow. The default schedule runs every six hours.\n\n### Customization tips\nChange the schedule, raise the percentage threshold to ignore small changes, or connect **Price change alerts** to Slack, email, Telegram, or Google Sheets.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "fetch-section-sticky",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -60,
        220
      ],
      "parameters": {
        "color": 7,
        "width": 1080,
        "height": 440,
        "content": "## 1. Configure and fetch listings\nRun manually or on schedule, enter up to 100 listing IDs, and fetch their current public data from Etsy in one authenticated batch request.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "compare-section-sticky",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1080,
        220
      ],
      "parameters": {
        "color": 7,
        "width": 900,
        "height": 440,
        "content": "## 2. Compare and route changes\nSave a baseline inside n8n, compare prices on later production runs, and route meaningful changes to the alert output.\n"
      },
      "typeVersion": 1
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Changes found?": {
      "main": [
        [
          {
            "node": "Price change alerts",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Baseline or no changes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check every 6 hours": {
      "main": [
        [
          {
            "node": "Configuration (EDIT ME)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run tracker manually": {
      "main": [
        [
          {
            "node": "Configuration (EDIT ME)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuration (EDIT ME)": {
      "main": [
        [
          {
            "node": "Build Etsy batch request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Etsy batch request": {
      "main": [
        [
          {
            "node": "Fetch public Etsy listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch public Etsy listings": {
      "main": [
        [
          {
            "node": "Compare prices with previous run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare prices with previous run": {
      "main": [
        [
          {
            "node": "Changes found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}