← Back to blogStrategy

The Etsy Seller's Trend Radar: Find Rising Products Before They Peak

· 12 min read

If you sell print-on-demand or handmade goods on Etsy, your hardest decision is not how to make a product — it is which product to make next. By the time a niche is obviously hot, the search results are already three hundred listings deep and the established shops have the reviews, the SEO, and the page-one ranking. The money is in entering a niche while it is rising — after it has shown real demand but before it saturates. The problem is that you cannot see "rising" in a single look at the search results. A snapshot tells you how crowded a niche is today; it tells you nothing about the direction it is moving.

This guide builds a trend radar: a small pipeline that measures the same basket of Etsy search queries on a weekly cadence, records how fast new listings are appearing, how favorites and reviews are growing, and how many listings are brand-new, then computes week-over-week velocity so you can separate niches that are climbing from ones that already peaked. The example basket is a handful of POD keywords, but the same code works for any set of queries — swap the strings and you have a radar pointed at your own market.

Why a Single Snapshot Can't Show a Trend

Everything you can learn from one Etsy search is a level: there are 240 listings for "cottagecore sweatshirt," the median price is $32, the top results have a few thousand favorites. Levels are useful for sizing a niche, but they are worthless for timing it. A niche with 240 listings could be a dead category that peaked two years ago and has been coasting ever since, or a category that had 90 listings last month and is doubling. Those two markets demand opposite decisions — avoid the first, sprint into the second — and they look identical in a single snapshot.

The signal you actually want is a derivative: the rate of change. That only exists across at least two measurements separated in time. Concretely, three quantities matter for a POD niche, and all three only become trend signals when you difference them week over week:

  • Listing velocity — how many new listings appear per week. Rising fast means sellers are piling in; that is both a demand signal and an early saturation warning.
  • Favorites growth — the median favorite count on top listings climbing means shoppers are actively buying-intent on the niche, not just sellers crowding it.
  • New-listing share — the fraction of results that are under ~30 days old. A high and rising share means the niche is young and accelerating; a low share means it is mature and settled.

The trick is that none of these is observable from one pull. You have to measure the same query repeatedly, store each measurement with a timestamp, and diff consecutive cycles. The rest of this guide is how to take those measurements cleanly and turn them into a table you can read at a glance.

Step 1: Define Your Candidate Basket

A radar needs a fixed set of things to watch. Pick 10–30 candidate queries that span the niches you would realistically design for — broad enough to catch a surprise, narrow enough that the signal is about your market. Each query maps to an Etsy search URL of the form https://www.etsy.com/search?q=QUERY.

import urllib.parse

# Candidate POD/handmade niches to watch. Keep this list stable across weeks
# so the week-over-week diff compares like with like.
QUERIES = [
    "cottagecore sweatshirt",
    "retro groovy tshirt",
    "coquette bow sweatshirt",
    "boho nursery print",
    "matcha lover mug",
    "pickleball shirt",
    "in my era sweatshirt",
    "dark academia poster",
]


def search_url(query):
    q = urllib.parse.quote_plus(query)
    return f"https://www.etsy.com/search?q={q}"


for q in QUERIES:
    print(q, "->", search_url(q))

Keep this list stable week to week. The whole value of the radar is comparing the same query against its own past, so resist the urge to swap candidates between cycles — add new ones if you like, but do not retire a query just because last week looked flat. Flat-then-spiking is exactly the pattern you are hunting.

Step 2: Pull One Query's Search Results

The Etsy search endpoint takes one search URL and a page count, scrapes the listings, and returns title, price, shop, and the favorite/review signals when Etsy exposes them. Every call is asynchronous: you submit, get the job id back immediately, then poll. That async shape is not optional — api.logposervices.com sits behind Cloudflare, which kills any single connection at roughly 90 seconds, and a multi-page search can run longer than that. Submit, let it run server-side, and poll for the result.

Confirm one query works with curl before you script the basket:

# 1) Submit one search — returns a job id immediately
curl -G "https://api.logposervices.com/api/v1/ecommerce/etsy/search" \
  -H "X-API-Key: lp_xxxxxxx" \
  --data-urlencode "url=https://www.etsy.com/search?q=cottagecore+sweatshirt" \
  --data-urlencode "pages=3"
# → {"job_id": "etsy_5c1d...", "status": "pending"}

# 2) Poll the job until status == "completed"
curl -H "X-API-Key: lp_xxxxxxx" \
  https://api.logposervices.com/api/v1/jobs/etsy_5c1d

# 3) Fetch the listings
curl -H "X-API-Key: lp_xxxxxxx" \
  https://api.logposervices.com/api/v1/jobs/etsy_5c1d/result

pages=3 is usually enough for a trend measurement — you are not trying to enumerate every listing in the niche, you are sampling the top of the results consistently every week. The important thing is to use the same page count for every query on every cycle, because listing counts and median favorites are only comparable when the sample size is held constant.

Step 3: Submit the Basket and Poll It

For a whole basket you are submitting a dozen-plus jobs, so the right pattern is fire-all-then-poll: submit every query up front (each returns instantly with a job id), then poll the outstanding job ids in a loop until they all finish. This runs the basket in parallel server-side instead of waiting on each query in sequence.

import os, time, requests

API_KEY = os.environ["LOGPOSE_API_KEY"]
BASE = "https://api.logposervices.com/api/v1"
HEADERS = {"X-API-Key": API_KEY}


def submit(url, pages=3):
    r = requests.get(
        f"{BASE}/ecommerce/etsy/search",
        params={"url": url, "pages": pages},
        headers=HEADERS, timeout=30,
    )
    r.raise_for_status()
    return r.json()["job_id"]


def collect(job_map, poll_every=5, timeout_s=900):
    """job_map: {job_id: query}. Returns {query: [listing rows]}."""
    pending = set(job_map)
    out, deadline = {}, time.time() + timeout_s
    while pending and time.time() < deadline:
        for jid in list(pending):
            s = requests.get(f"{BASE}/jobs/{jid}", headers=HEADERS, timeout=15).json()
            status = s.get("status")
            if status == "completed":
                res = requests.get(f"{BASE}/jobs/{jid}/result",
                                   headers=HEADERS, timeout=30).json()
                out[job_map[jid]] = res.get("listings", [])
                pending.discard(jid)
            elif status == "failed":
                print(f"  query '{job_map[jid]}' failed: {s.get('error')}")
                out[job_map[jid]] = []
                pending.discard(jid)
        if pending:
            time.sleep(poll_every)
    return out


# Submit the whole basket, then poll it
job_map = {submit(search_url(q)): q for q in QUERIES}
print(f"submitted {len(job_map)} query jobs")
results = collect(job_map)
print(f"collected results for {len(results)} queries")

Submitting first and polling second is what turns a basket of fifteen queries from fifteen sequential waits into a single few-minute drain of the queue — the jobs run concurrently up to your account's concurrency cap while your script watches.

Step 4: Reduce Each Query to a Weekly Metric Row

Raw listings are not the radar — the metrics are. For each query, reduce its listings down to the four numbers that matter, stamp them with the date, and append to a running history file. The history is what makes everything downstream possible; without a stored time series there is no week-over-week.

import statistics, datetime, json, os


def days_old(row):
    """Best-effort listing age in days from whatever Etsy exposed."""
    age = row.get("age_days")
    if age is not None:
        return age
    listed = row.get("listed_at") or row.get("created_at")
    if listed:
        d = datetime.date.fromisoformat(listed[:10])
        return (datetime.date.today() - d).days
    return None


def metrics_for(query, rows):
    favs = [r["favorites"] for r in rows if r.get("favorites") is not None]
    revs = [r["reviews"] for r in rows if r.get("reviews") is not None]
    ages = [a for a in (days_old(r) for r in rows) if a is not None]
    new_share = (sum(1 for a in ages if a <= 30) / len(ages)) if ages else None
    return {
        "date": datetime.date.today().isoformat(),
        "query": query,
        "listing_count": len(rows),
        "median_favorites": round(statistics.median(favs)) if favs else 0,
        "median_reviews": round(statistics.median(revs)) if revs else 0,
        "new_listing_share": round(new_share, 3) if new_share is not None else None,
    }


HISTORY = "etsy_trend_history.jsonl"


def append_history(rows_by_query):
    with open(HISTORY, "a", encoding="utf-8") as f:
        for q, rows in rows_by_query.items():
            f.write(json.dumps(metrics_for(q, rows)) + "\n")


append_history(results)
print("appended this week's metrics to", HISTORY)

Note the defensiveness around favorites, reviews, and listing age: Etsy does not always expose every signal on every listing, so each metric is computed only from the rows that carry it. A median over the rows that have favorites is a far more stable weekly comparison than a count that silently swings when Etsy changes what it renders. Use the product endpoint (next step) when you need richer per-listing detail than search returns.

Step 5: Backfill Detail on the Movers (Optional)

Search gives you enough to compute the radar, but the favorite and review fields are sometimes thin or absent in search results. When a query lights up and you want to confirm the signal is real before committing a design week to it, pull the top few listing URLs through the product endpoint, which returns fuller per-listing detail — favorites, reviews, and a clearer listing age.

curl -G "https://api.logposervices.com/api/v1/ecommerce/etsy/product" \
  -H "X-API-Key: lp_xxxxxxx" \
  --data-urlencode "url=https://www.etsy.com/listing/1234567890/example-listing"
# → {"job_id": "etsy_a90f...", "status": "pending"}  (poll as before)
def product_detail(listing_url):
    jid = requests.get(
        f"{BASE}/ecommerce/etsy/product",
        params={"url": listing_url},
        headers=HEADERS, timeout=30,
    ).json()["job_id"]
    # reuse the same poll loop pattern as collect()
    detail = collect({jid: listing_url}, timeout_s=300)
    return detail.get(listing_url)


# Confirm a rising query: pull detail on its top 3 listings
top = sorted(results["coquette bow sweatshirt"],
             key=lambda r: r.get("favorites") or 0, reverse=True)[:3]
for r in top:
    if r.get("url"):
        print(product_detail(r["url"]))

Treat this as a confirmation step, not part of the weekly sweep — running every listing through the product endpoint every week is wasteful. Reserve it for the two or three queries the radar flagged as moving, where the cost of being wrong (a wasted design cycle) justifies the extra detail.

Step 6: Compute Velocity and Read the Radar

With at least two cycles in history, the payoff arrives: difference each query against its own previous week and rank by velocity. This is the table you actually look at — it turns the stored time series into a rising-versus-saturated verdict per niche.

import collections


def load_history():
    by_query = collections.defaultdict(list)
    with open(HISTORY, encoding="utf-8") as f:
        for line in f:
            m = json.loads(line)
            by_query[m["query"]].append(m)
    for q in by_query:
        by_query[q].sort(key=lambda m: m["date"])
    return by_query


def velocity_table(by_query):
    table = []
    for q, hist in by_query.items():
        if len(hist) < 2:
            continue
        prev, cur = hist[-2], hist[-1]
        dc = cur["listing_count"] - prev["listing_count"]
        df = cur["median_favorites"] - prev["median_favorites"]
        # Verdict: favorites climbing + listings not yet flooded = rising.
        share = cur["new_listing_share"] or 0
        if df > 0 and share >= 0.30:
            verdict = "RISING"
        elif df > 0 and dc > 0:
            verdict = "heating"
        elif df <= 0 and dc <= 0:
            verdict = "saturated/cooling"
        else:
            verdict = "watch"
        table.append((q, dc, df, share, verdict))
    table.sort(key=lambda r: r[2], reverse=True)  # by favorites velocity
    return table


for q, dc, df, share, verdict in velocity_table(load_history()):
    print(f"{q:28s} listings {dc:+4d}  favs {df:+5d}  "
          f"new {share:.0%}  -> {verdict}")

The output reads like a dashboard:

QueryListings ΔFavorites ΔNew-listing shareVerdict
coquette bow sweatshirt+18+34046%RISING
matcha lover mug+9+12034%RISING
pickleball shirt+52+6022%heating
in my era sweatshirt+3-8012%saturated/cooling
dark academia poster-4-409%saturated/cooling

The distinction the radar buys you is in the bottom three rows. "Pickleball shirt" has the highest listing growth of all — a naive snapshot would read that as the hottest niche — but its favorites are barely moving and its new-listing share is low, which is the fingerprint of a niche where sellers are flooding in faster than buyers care: it is heating into saturation, not rising. Meanwhile "coquette bow sweatshirt" has fewer new listings but strong favorites growth and a young, fast-turning result set — demand outpacing supply, which is precisely the window to enter. The verdict column encodes that: favorites direction separates real demand from seller pile-on, and new-listing share separates young niches from mature ones.

Scaling This Into a Standing Radar

Everything above works, but it leaves you holding two annoying responsibilities: running the sweep on a reliable weekly cadence, and storing the time series so the diff has something to compare against. That is a cron job and a state file you now have to babysit — and if your laptop is asleep on sweep day, the radar has a hole in it.

That scheduling-plus-state burden is exactly what LogPose's monitor primitive removes. A monitor polls a saved Etsy search on a fixed cadence and stores the resulting time series for you, so you stop hosting the cron and the history file yourself. You point a monitor at each candidate query's search URL on a weekly interval, and it accumulates the same week-over-week measurements the radar runs on — then fires a notification when a query crosses a velocity threshold, so a rising niche surfaces in your inbox instead of waiting for you to remember to read the table.

# A weekly monitor on one candidate query, alerting when favorites velocity
# crosses a threshold (check_interval_hours: 168 = once per week)
curl -X POST "https://api.logposervices.com/api/v1/monitors" \
  -H "X-API-Key: lp_xxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.etsy.com/search?q=coquette+bow+sweatshirt",
    "name": "Etsy radar: coquette bow sweatshirt",
    "metric": "favorites",
    "condition": "increase",
    "threshold": 100,
    "check_interval_hours": 168,
    "notify_channels": ["email"]
  }'

Register one monitor per query in your basket and the radar becomes a service that watches itself: the time series lives server-side, the weekly sweep happens whether or not your machine is on, and you only hear about a query when it actually moves. notify_channels accepts email, webhook, telegram, slack, and discord — a webhook target lets you stream the alerts straight into your own design backlog, and you can still export the accumulated trend table whenever you want the full picture rather than just the threshold crossings.

The Honest Fit

This approach fits well when you are an Etsy or POD seller deciding what to design next and you want to time your entry — catching a niche on the way up rather than after it floods. The discipline of measuring the same basket every week, holding the sample size constant, and reading velocity instead of levels is what separates an early-mover advantage from chasing yesterday's trend. The monitor primitive makes that discipline automatic instead of a chore you skip.

Where it is not the right tool: this is an Etsy-internal supply-and-demand signal, not a macro one. It measures what is moving inside Etsy's marketplace — it will not tell you a trend is breaking out on TikTok or peaking on Google Trends before it reaches Etsy, and for cross-platform demand sensing you want a different instrument. And the caveat worth repeating: a rising radar reading lowers the odds you are entering a saturated graveyard, but it does not sell the product for you. Photography, title and tag SEO, pricing against the incumbents, and your shop's review history still decide conversion. Use the radar to point your effort at the right niche, then win the listing on execution.

Get Started

  1. Sign up at logposervices.com and generate an API key under Tool → API Keys.
  2. export LOGPOSE_API_KEY=lp_xxxxxxx
  3. Test one query, then build the basket and start the history:
curl -G "https://api.logposervices.com/api/v1/ecommerce/etsy/search" \
  -H "X-API-Key: lp_xxxxxxx" \
  --data-urlencode "url=https://www.etsy.com/search?q=cottagecore+sweatshirt" \
  --data-urlencode "pages=3"

Then run the basket through submit / collect, reduce each query to a weekly metric row, append it to your history, and — once you have two cycles — read the velocity table. To make the sweep run itself, register one weekly monitor per query and export the accumulated trend table whenever you need the full view.

Related reading: How to find trending Etsy products before they peak for the single-snapshot fundamentals, How to scrape a Shopify store's product catalog for catalog-level extraction, and Competitor price monitoring for the scheduled-monitor pattern applied to pricing.

External: Etsy, hiQ Labs v. LinkedIn.

Frequently asked questions

Is it legal to scrape public Etsy listings?
Etsy search results and listing pages are public — titles, prices, favorite counts, review counts, and shop names are displayed without authentication to anyone who opens the page. Scraping public web data is not a CFAA violation in the United States, per hiQ Labs v. LinkedIn (9th Cir. 2022), which held that accessing publicly available information does not constitute unauthorized access under the statute. Etsy's Terms of Service do restrict automated access to its private APIs and bulk republication of its catalog as a competing product — neither of which describes pulling listing counts and favorite figures into your own spreadsheet to inform what you design next. The data this pipeline reads is the same data a shopper browsing Etsy sees. Where you do owe care is volume and respect: pace your requests, do not hammer the site, and do not redistribute scraped listings wholesale. You are measuring an aggregate trend signal, not copying anyone's product.
Does a rising trend on Etsy mean the niche will actually sell for me?
No — and conflating the two is the most common mistake. New-listing velocity and favorites growth measure supply and demand inside Etsy's marketplace: they tell you a niche is heating up, which is genuinely useful for timing your entry before the category floods. But they say nothing about whether your specific listing will convert. On Etsy, conversion is decided by your photography, your title and tag SEO, your price relative to the established sellers, and your shop's review history — none of which a trend signal can supply. Treat the radar as a targeting tool that tells you where to point your design effort, then win the listing on execution. A rising niche lowers the odds you are entering a saturated graveyard; it does not guarantee sales.

Related posts

Tutorial

How to Find Trending Etsy Products Before They Peak

11 min read
Strategy

How a Cold-Email Agency Pulls 500 Fresh Local Leads a Week

12 min read
Strategy

The Deal Scout's Weekly Funding Digest from Crunchbase

12 min read