Ask a small online shop owner if their products show up in the Google Shopping tab. Most will say “I think so, the plugin is installed.” Then open their Merchant Center account with them. You will often find a wall of red: 40% of products “Disapproved” or “Limited”, missing GTINs, prices on the landing page that do not match the feed, images with “PROMO 50%” stamped on them, and sometimes a full account suspension for “misrepresentation” that nobody noticed for months.

Those products are invisible in free listings and cannot run in Shopping ads. The owner is paying for a store and leaving free Google traffic on the table.

That is a service you can sell starting today: audit the product feed, fix the boring data problems, rewrite weak titles so they match how people search, and keep it clean every month. The audit is mostly mechanical, so a script does 80% of it.

In this guide:

  • what Google actually requires in a product feed, with the real limits
  • a Python auditor that checks a feed export for the most common disapproval causes, including GTIN check digits and broken image links
  • a Gemini step that rewrites product titles without inventing attributes
  • pricing, costs, realistic earnings, and the suspension and “guaranteed reinstatement” traps

Why this sells

Since 2020 Google has shown products in the Shopping tab for free (“free listings”, now usually called unpaid product listings), and later expanded them to many more countries. Check the current country list in the Merchant Center Help Center before pitching a market, it changes. Any shop with a product feed can appear there without spending on ads.

The catch is data quality. Google rejects or limits products that break its product data specification or Shopping policies, and the owner of a small shop rarely understands why. Shopify’s Google & YouTube app and the free Google for WooCommerce plugin make it easy to connect a store, but they push whatever data the store has. Bad data in, disapprovals out.

What people pay

Search Fiverr and Upwork for “google merchant center” and you will find hundreds of gigs. At the time of writing, typical listings fall roughly into:

Gig typeCommon price range (check current listings)
Merchant Center setup + feed connection$30-$150
Fix disapproved products / feed errors$50-$250 depending on product count
“Suspension fix” / misrepresentation appeals$100-$500, often with “guaranteed” claims you should not copy
Monthly feed management$100-$500/month for small catalogues

These ranges move. Spend 15 minutes browsing current gigs in your target market before you set a price.

For local Indonesian shops I would price like this:

PackageWhat is includedSuggested price
Free auditScreenshot + a 1-page report: number of disapproved products, top 5 causesFree (it is your pitch)
Feed fix, up to 200 productsFix identifiers, images, prices, titles; resubmit; before/after reportRp 1-2.5 juta
Feed fix, 200-2,000 productsSame, with a supplemental feed in Google SheetsRp 2.5-5 juta
Monthly careWeekly diagnostics check, new products cleaned, re-run auditorRp 300-750k/month

That is my pricing logic for small businesses, not a market survey. The monthly plan is where it turns semi-passive: a cron job runs the auditor and you only act on what it flags.

What you need

ItemCostNotes
Python 3.10+, requests, google-genai$0Any laptop
Gemini API key$0 to startaistudio.google.com
Client feed export (CSV/TSV)$0From Merchant Center, Shopify (Products > Export) or WooCommerce
Google Sheets$0Used as a supplemental feed, which Merchant Center supports natively
Access to the client Merchant Center$0Ask to be added as a user (Standard access). Never ask for their Google password
Your own test Merchant Center account$0Practice before touching a client account

Gemini pricing (check ai.google.dev/pricing before relying on it): Gemini 3.5 Flash-Lite paid tier is $0.30 per 1M input tokens and $2.50 per 1M output tokens. One title rewrite is about 400 tokens in and 60 out, so roughly $0.0003 per product. Rewriting 2,000 titles costs about $0.55. Flash-Lite also has a free tier, but its per-minute and per-day request limits are low and change often; check your project at aistudio.google.com/rate-limit. For one small shop it is usually enough if you run the script slowly.

The rules your auditor checks

From Google’s Product data specification (Merchant Center Help), the limits that cause most problems for small shops:

AttributeRule
idRequired, max 50 characters, must stay stable over time
titleRequired, max 150 characters. Google recommends putting the most important details first
descriptionRequired, max 5,000 characters, no promotional text or links to other sites
linkRequired, must be on your verified domain
image_linkRequired. Minimum 100 x 100 px (250 x 250 for apparel). No promotional overlays, watermarks or borders
priceRequired, must match the landing page and checkout, with ISO 4217 currency (e.g. 150000 IDR)
availabilityRequired: in_stock, out_of_stock, preorder or backorder
conditionRequired if the product is used or refurbished: new, refurbished, used
gtinRequired for products with a manufacturer-assigned GTIN. Must pass the GS1 check digit
brandRequired for most new products (except films, books, music)
identifier_existsSet to no for custom, handmade or private-label products with no GTIN

The two classics: shops typing random numbers into the GTIN field “because it was required” (Google flags invalid GTINs), and handmade sellers leaving GTIN empty without setting identifier_exists to no.

Step 1: Get access the right way

Ask the client to add your Google account as a user in Merchant Center (Settings > People and access > Add person) with Standard access. Do not take their Google password and do not ask for Admin unless you really need it. When the job ends, they remove you in one click. That protects both of you.

Then open Products > Needs attention (or the Diagnostics view, depending on the interface) and take a screenshot of the issue counts. That screenshot is your “before” for the report.

Step 2: Export the feed and set up

Download the product list from Merchant Center, or the product CSV from Shopify/WooCommerce. The auditor below expects Merchant Center attribute names (id, title, gtin, …). If your export uses other headers, edit the COLUMNS map.

mkdir feedfix && cd feedfix
python3 -m venv .venv && source .venv/bin/activate
pip install requests pillow google-genai
export GEMINI_API_KEY="your-gemini-key"

Step 3: Run the auditor

Save as audit.py:

import csv, io, re, sys
from concurrent.futures import ThreadPoolExecutor
import requests
from PIL import Image

# map your export headers to Merchant Center names if they differ
COLUMNS = {"Handle": "id", "Title": "title", "Body (HTML)": "description", "Variant Barcode": "gtin",
           "Vendor": "brand", "Image Src": "image_link"}

PROMO = re.compile(r"\b(free shipping|gratis ongkir|diskon|promo|sale|termurah|best seller|cod)\b|!{2,}", re.I)
PRICE = re.compile(r"^\d+(\.\d{1,2})? [A-Z]{3}$")
AVAIL = {"in_stock", "out_of_stock", "preorder", "backorder"}
COND = {"", "new", "refurbished", "used"}

def gtin_ok(g):
    if not g.isdigit() or len(g) not in (8, 12, 13, 14):
        return False
    d = [int(c) for c in g.zfill(14)]
    total = sum(x * (3 if i % 2 == 0 else 1) for i, x in enumerate(d[:13]))
    return (10 - total % 10) % 10 == d[13]

def image_issue(url):
    try:
        r = requests.get(url, timeout=20, headers={"User-Agent": "feed-audit/1.0"})
        if r.status_code != 200:
            return f"image HTTP {r.status_code}"
        w, h = Image.open(io.BytesIO(r.content)).size
        return f"image too small {w}x{h}" if min(w, h) < 250 else None
    except Exception as e:
        return f"image unreadable ({type(e).__name__})"

def check(p):
    issues = []
    if not p.get("id") or len(p["id"]) > 50:
        issues.append("id missing or > 50 chars")
    t = p.get("title", "")
    if not t:
        issues.append("title missing")
    elif len(t) > 150:
        issues.append(f"title {len(t)} chars (max 150)")
    elif len(t) < 25:
        issues.append("title very short, likely weak for search")
    if PROMO.search(t) or t.isupper():
        issues.append("promotional text or ALL CAPS in title")
    desc = p.get("description", "")
    if not desc or len(desc) > 5000:
        issues.append("description missing or > 5000 chars")
    if p.get("price") and not PRICE.match(p["price"].strip()):
        issues.append("price format: " + repr(p["price"]))
    if p.get("availability", "in_stock").strip().lower().replace(" ", "_") not in AVAIL:
        issues.append("invalid availability value")
    if p.get("condition", "").strip().lower() not in COND:
        issues.append("invalid condition value")
    g = p.get("gtin", "").strip().lstrip("'")
    no_id = p.get("identifier_exists", "").strip().lower() in ("no", "false")
    if g and not gtin_ok(g):
        issues.append(f"invalid GTIN check digit: {g}")
    if not g and not no_id and not p.get("mpn"):
        issues.append("no GTIN/MPN: set identifier_exists=no if custom/handmade")
    if not p.get("brand") and not no_id:
        issues.append("brand missing")
    return issues

def main(path):
    raw = open(path, encoding="utf-8-sig").read()
    dialect = csv.Sniffer().sniff(raw[:5000], delimiters=",\t")
    rows = [{COLUMNS.get(k, k): (v or "") for k, v in r.items()}
            for r in csv.DictReader(io.StringIO(raw), dialect=dialect)]
    with ThreadPoolExecutor(8) as ex:
        img = list(ex.map(lambda r: image_issue(r["image_link"]) if r.get("image_link") else "image_link missing", rows))
    counts, out = {}, []
    for r, im in zip(rows, img):
        issues = check(r) + ([im] if im else [])
        for i in issues:
            key = i.split(":")[0].split(" (")[0]
            counts[key] = counts.get(key, 0) + 1
        if issues:
            out.append({"id": r.get("id"), "title": r.get("title", "")[:80], "issues": " | ".join(issues)})
    with open("issues.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["id", "title", "issues"])
        w.writeheader(); w.writerows(out)
    print(f"{len(out)}/{len(rows)} products with issues\n")
    for k, v in sorted(counts.items(), key=lambda x: -x[1])[:10]:
        print(f"{v:5}  {k}")

if __name__ == "__main__":
    main(sys.argv[1])

Run it:

python audit.py products.tsv

Typical output on a messy shop:

212/340 products with issues

  118  no GTIN/MPN
   64  promotional text or ALL CAPS in title
   41  invalid GTIN check digit
   23  image too small 200x200
    9  image HTTP 404

(That output is illustrative, not a real client.) The script uses the stricter 250 px apparel minimum for everyone, on purpose: tiny images look bad even where they are allowed. It cannot detect text overlays on images, so scroll the image column by eye or check Merchant Center diagnostics, which flags them.

Price mismatches are the other big one, and they need the live site. Open 10 random product links on mobile, compare the price and stock with the feed, and check whether a sale price in the store is also in the feed as sale_price. A stale price feed is a common cause of disapprovals and can escalate to account-level warnings.

Step 4: Rewrite weak titles with Gemini (without inventing facts)

Small-shop titles are often “Kemeja Keren Murah!!” or just a SKU. Google’s guidance is to put the important details first: brand, product type, then attributes like material, color and size. The danger with an LLM is that it “helpfully” adds a size or material that is not true. That is how you create a mismatch with the landing page.

So the script gives the model only the fields in the feed, asks for JSON, then rejects any title containing a number that does not appear in the source data.

Save as titles.py:

import csv, json, re, sys, time
from google import genai
from google.genai import types

MODEL = "gemini-3.5-flash-lite"
FIELDS = ["title", "brand", "product_type", "color", "size", "material", "gender", "description"]
client = genai.Client()

PROMPT = """Rewrite this Google Shopping product title.
Rules:
- Max 150 characters. Order: brand, product type, key attributes (material, color, size, model).
- Use ONLY facts present in the data below. If a fact is not there, leave it out.
- No promotional words (promo, diskon, murah, free shipping), no ALL CAPS, no emoji.
- Keep the language of the original title.
Return JSON: {"title": "...", "facts_used": ["..."]}

DATA:
"""

def numbers(s):
    return set(re.findall(r"\d+(?:[.,]\d+)?", s))

def rewrite(p):
    data = {k: p.get(k, "")[:600] for k in FIELDS if p.get(k)}
    resp = client.models.generate_content(
        model=MODEL,
        contents=PROMPT + json.dumps(data, ensure_ascii=False),
        config=types.GenerateContentConfig(response_mime_type="application/json", temperature=0.2),
    )
    new = json.loads(resp.text)["title"].strip()
    source = " ".join(data.values())
    invented = numbers(new) - numbers(source)
    if len(new) > 150 or invented:
        return None, f"rejected: len={len(new)} invented={sorted(invented)}"
    if p.get("brand") and p["brand"].lower() not in new.lower():
        return None, "rejected: brand dropped"
    return new, "ok"

def main(path, ids_path):
    todo = {l.strip() for l in open(ids_path) if l.strip()}
    dialect = csv.Sniffer().sniff(open(path, encoding="utf-8-sig").read(5000), delimiters=",\t")
    rows = [r for r in csv.DictReader(open(path, encoding="utf-8-sig"), dialect=dialect) if r["id"] in todo]
    with open("supplemental_titles.csv", "w", newline="", encoding="utf-8") as f, \
         open("review.csv", "w", newline="", encoding="utf-8") as g:
        sup, rev = csv.writer(f), csv.writer(g)
        sup.writerow(["id", "title"]); rev.writerow(["id", "old", "new", "status"])
        for r in rows:
            try:
                new, status = rewrite(r)
            except Exception as e:
                new, status = None, f"error: {e}"
            rev.writerow([r["id"], r["title"], new or "", status])
            if new:
                sup.writerow([r["id"], new])
            time.sleep(4)  # ~15 requests/min to stay under free-tier limits; lower it on the paid tier

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

Unlike audit.py, this script expects Merchant Center column names (id, title, brand, …), so feed it the Merchant Center export, not the Shopify CSV. That matters for Step 5 too: the supplemental feed matches products by the Merchant Center id (the Shopify app uses IDs like shopify_ZZ_123..._456..., not the product handle).

Pick the IDs to rewrite from issues.csv (short titles, promo text, ALL CAPS) and run:

python -c "import csv; [print(r['id']) for r in csv.DictReader(open('issues.csv'))]" > ids.txt
python titles.py products.tsv ids.txt

Read review.csv before anything goes live. The number check catches invented sizes and capacities. It does not catch an invented word like “leather”. You or the client must approve every title. If gemini-3.5-flash-lite is retired, switch MODEL to the current Flash-Lite model.

Step 5: Push fixes with a supplemental feed

You usually do not want to edit 300 products in the store admin by hand. Merchant Center lets you add a supplemental data source that overrides attributes by id:

  1. Create a Google Sheet. Column A id, then one column per attribute you fix: title, identifier_exists, brand, gtin.
  2. Paste the approved rows from supplemental_titles.csv. For handmade or private-label items with no barcode, put no in identifier_exists and delete the fake GTIN in the store.
  3. In Merchant Center: gear icon (Settings) > Data sources > Supplemental sources > Add supplemental product data, choose the Google Sheets option, and link it to the primary data source with the same country and language. If Merchant Center will not pick up an existing sheet, start from Google’s Sheets template and paste your rows into it.
  4. Wait for the next processing run (often within a day), then check diagnostics again.

Some fixes must happen in the store itself: prices, stock, images with overlays, and missing shipping or return policy pages. A supplemental feed that says one thing while the landing page says another is exactly what Google penalizes.

Step 6: Report and resubmit

Send the client a short before/after: disapproved count, issues fixed, what still needs their input (real GTINs from suppliers, new photos). If the account had an account-level issue, request a review only after everything is fixed and you have checked the store against Google’s requirements for contact info, return policy and checkout. Failed review requests can lead to a waiting period before you can try again, so do not “just try” a review.

Honest cost breakdown

ItemMonthly cost
Python, Pillow, Google Sheets, Merchant Center$0
Gemini, 5 clients x 500 titlesabout $0.70 (paid tier) or $0 on the free tier
Optional: a cheap VPS for the monthly cron runabout $5-7. Hetzner retired the CX22 and raised cloud prices in 2026, and its cheapest plans are sometimes listed as unavailable; check current pricing or just run the cron on your own laptop
Your timethe real cost

Software costs are close to zero. Your margin is your time and how fast you get with a checklist.

Realistic earnings and time

  • First client: 6-10 hours, because you are learning the interface and the policies. Do your own test store first.
  • After 3-5 clients: a 300-product fix takes about 2-4 hours.
  • Pipeline: if you send 10 free audits a week to shops that already run a Shopify or WooCommerce store, a 10% close rate is a reasonable starting assumption (it is mine, not data). That is about one paid fix a week.
  • Example month: 4 fixes at Rp 1.5 juta + 5 monthly care clients at Rp 500k = Rp 8.5 juta, for roughly 30-40 hours of work. Month one will be far below that. Treat it as a target for month three or four.

Where to find clients: shops running Google Shopping ads (search a product, look at sponsored product cards, then check their free listings), Shopify stores in your city, and WooCommerce shops whose “Google” plugin you can spot in the page source. The free audit, with their disapproved count, is the pitch.

Risks: suspensions, ToS and the grey offers you should not sell

Account suspension is the client’s risk, and you can make it worse. Merchant Center suspends accounts for things like misrepresentation, unavailable checkout, or price mismatches. If you hide a problem instead of fixing it, you push the account toward suspension.

Do not create new accounts to escape a suspension. Some gigs offer “fresh Merchant Center account” setups for suspended stores. Google’s Circumventing systems policy covers this, and enforcement can extend to linked accounts. It is a fast way to get a client’s domain permanently blocked. Decline.

Do not invent GTINs. Buying random barcodes or generating numbers that pass the check digit is still false data. Google can match GTINs against the GS1 database and the brand. Use real supplier barcodes or identifier_exists = no.

Do not cloak prices. Showing a lower price in the feed than on the site, or different content to Googlebot, is misrepresentation. It is not a loophole, it is the exact behavior the policy targets.

Never promise reinstatement. You do not control Google’s review. Sell “all identified feed issues fixed, with a before/after report.” On Fiverr and Upwork, a “guaranteed” promise you cannot keep turns into refunds, disputes and bad reviews.

Access and data. Work through user access, not passwords. Do not keep client exports longer than you need. If you use the free Gemini tier, read Google’s terms on how free-tier inputs may be used. For clients with sensitive catalogues, use the paid tier.

Reality check

Free listings are not a traffic flood. Fixing a feed makes products eligible. Clicks depend on price, demand and competition. Sell “your products can appear now”, not “sales will double.”

Many disapprovals are business problems, not data problems. No return policy page, no contact details, a checkout that breaks on mobile, products Google restricts. Your script cannot fix those. Say so in the audit.

The plugin will undo your work. Shopify and WooCommerce apps re-sync from the store. If you fix something only in Merchant Center, the next sync can overwrite it. Use supplemental feeds, or fix at the source.

Rewriting titles is not SEO magic. Better titles help Google match search queries, but a title stuffed with keywords looks spammy and can break the “accurate title” rule. Keep it factual.

Interfaces move. Merchant Center menus have changed several times, and the old Content API for Shopping reached its sunset on August 18, 2026; tools still on it are being moved to the Merchant API (feeds via Sheets or file uploads are not affected). If a menu path in this guide does not match, search the Merchant Center Help Center for the current one.

Common beginner mistakes: editing the client’s live store without a backup export, mass-changing titles without approval, forgetting sale_price during a promo, and quoting before seeing the account.

Final checklist

  • Practice on your own test store and Merchant Center account first
  • Client adds you as a user, no shared passwords
  • “Before” screenshot of diagnostics saved
  • audit.py run, issues.csv reviewed, 10 product pages spot-checked for price/stock
  • Titles rewritten with titles.py, every line in review.csv approved by a human
  • Fixes pushed via supplemental feed or at the source, never cloaked
  • No fake GTINs, no new accounts to dodge suspensions, no “guaranteed reinstatement”
  • Before/after report sent, monthly care offered

Start today: find five local shops with a Shopify or WooCommerce store, ask one of them for 15 minutes of Merchant Center access, and run the auditor. The first audit is your portfolio piece.