In March 2024 Google shut down the free websites that came with Google Business Profiles (the business.site pages). Visitors got redirected to the Maps listing until June 10, 2024, and after that the links just stopped working. Many small businesses, including UMKM that had never paid for a website, lost the only site they had. Plenty of others never had one.

You can find every one of them in a few minutes. Google’s Places API returns a business’s name, phone number, rating and, importantly, its websiteUri. If that field is empty and the business has 150 reviews, you’ve found an established business with customers and no website.

Most web freelancers pitch these businesses with “Do you need a website?” That rarely works. This guide uses a different approach: build their site first, then send them the link. A laundry owner who opens a WhatsApp message and sees their own name, address, hours and 4.7-star rating on a finished page reacts very differently from one who gets a price list.

You’ll build two Python scripts:

  • leads.py searches Places API for a category in a city, keeps only businesses with no website, a mobile number and real review volume, and writes a CSV
  • demos.py uses Gemini 3.5 Flash-Lite to write personalised copy for each lead, renders a clean one-page site, and generates a ready-to-send WhatsApp click-to-chat link for each one

Then we’ll cover hosting demos for free on Cloudflare Pages, what to charge, what it really costs, and the grey parts: Google’s data terms, using a business’s name without asking, and getting your WhatsApp number banned.

What you need

ItemCostNotes
Python 3.10+$0Any laptop
Google Cloud project with Places API (New) enabled$0 within the free capNeeds a billing account (card) on file
Gemini API key$0 to startaistudio.google.com → “Get API key”
requests, google-genai, pydantic$0pip install
Cloudflare account + Node.js (for npx wrangler)$0Free Pages plan hosts all your demos
A separate WhatsApp Business numberOne prepaid SIM, ~Rp 10.000–25.000Don’t pitch from your personal number (see Risks)

Places API pricing (since March 1, 2025 Google replaced the old $200 monthly credit with free usage caps per SKU; check developers.google.com/maps/billing-and-pricing/pricing before relying on these):

  • The websiteUri, phone and rating fields put a Text Search request in the Text Search Enterprise SKU: 1,000 free requests per month, then $35 per 1,000.
  • Text Search is billed per request, not per place, and each request returns up to 20 places. So the free cap covers up to ~20,000 places a month. You won’t get near that doing this by hand.

Gemini pricing (ai.google.dev/pricing): Gemini 3.5 Flash-Lite paid tier is $0.30 per 1M input tokens and $2.50 per 1M output tokens. One demo’s copy is ~600 input + ~500 output tokens, about $0.0015. A hundred demos costs roughly 15 cents (a bit more if the model uses thinking tokens, which are billed as output). The free tier in AI Studio also covers this volume, but free-tier prompts may be used by Google to improve its products.

Set up:

mkdir nosite && cd nosite
python3 -m venv .venv && source .venv/bin/activate
pip install requests google-genai pydantic

export PLACES_KEY="your-google-cloud-api-key"   # restrict it to Places API (New)
export GEMINI_API_KEY="your-gemini-key"

In Google Cloud Console: APIs & Services → Library → “Places API (New)” → Enable, then Credentials → Create API key → Restrict key → Places API (New). Also set a budget alert at $5 (Billing → Budgets & alerts). A loop bug that fires 10,000 requests would otherwise cost about $315.

Step 1: Pull the leads

Save as leads.py:

import csv, os, re, sys, time
import requests

URL = "https://places.googleapis.com/v1/places:searchText"
FIELDS = ",".join([
    "places.id", "places.displayName", "places.formattedAddress",
    "places.internationalPhoneNumber", "places.websiteUri", "places.rating",
    "places.userRatingCount", "places.businessStatus", "places.googleMapsUri",
    "places.regularOpeningHours", "places.primaryTypeDisplayName", "nextPageToken",
])
MIN_REVIEWS = int(os.environ.get("MIN_REVIEWS", 30))

def search(query):
    headers = {"X-Goog-Api-Key": os.environ["PLACES_KEY"], "X-Goog-FieldMask": FIELDS}
    body, places, requests_made = {"textQuery": query, "pageSize": 20}, [], 0
    while True:                      # max 3 pages = 60 places per query
        r = requests.post(URL, json=body, headers=headers, timeout=30)
        r.raise_for_status()
        requests_made += 1
        data = r.json()
        places += data.get("places", [])
        token = data.get("nextPageToken")
        if not token:
            break
        body["pageToken"] = token
        time.sleep(2)
    return places, requests_made

def wa_number(intl):
    digits = re.sub(r"\D", "", intl or "")
    return digits if digits.startswith("628") else ""   # Indonesian mobiles only; landlines can't WhatsApp

def main(queries, out="leads.csv"):
    seen, rows, total_req = set(), [], 0
    for q in queries:
        places, n = search(q)
        total_req += n
        for p in places:
            if p["id"] in seen or p.get("websiteUri"):
                continue
            seen.add(p["id"])
            phone = wa_number(p.get("internationalPhoneNumber"))
            reviews = p.get("userRatingCount", 0)
            if p.get("businessStatus") != "OPERATIONAL" or not phone or reviews < MIN_REVIEWS:
                continue
            hours = p.get("regularOpeningHours", {}).get("weekdayDescriptions", [])
            rows.append({
                "place_id": p["id"], "name": p["displayName"]["text"],
                "type": p.get("primaryTypeDisplayName", {}).get("text", ""),
                "address": p.get("formattedAddress", ""), "wa": phone,
                "rating": p.get("rating", ""), "reviews": reviews,
                "hours": " | ".join(hours), "maps": p.get("googleMapsUri", ""),
            })
    rows.sort(key=lambda r: r["reviews"], reverse=True)
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ["place_id"])
        w.writeheader()
        w.writerows(rows)
    print(f"{len(rows)} leads -> {out}  ({total_req} billable requests)")

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

Run it with a few category + area queries:

python leads.py "laundry di Sleman" "bengkel motor di Sleman" "klinik gigi di Sleman" "catering di Sleman"

Each query costs at most 3 requests. Four queries = 12 of your 1,000 free requests. The output is sorted by review count, because a business with 300 reviews and no website has money and customers. A business with 4 reviews probably doesn’t have either.

Pick categories where customers search before they buy and owners have money: dental clinics, workshops (bengkel), catering, wedding/MUA, guesthouses, tutoring centres, car rental. Warung and small kiosks have the highest no-website rate and the lowest willingness to pay.

Step 2: Generate the demo sites

The trick for consistent quality: Gemini writes only the copy, as structured JSON. Python renders the HTML. If you let the model write the whole page, you’ll get a broken layout every tenth time.

Save as demos.py:

import csv, html, os, re, sys, urllib.parse
from google import genai
from pydantic import BaseModel

MODEL = os.environ.get("DEMO_MODEL", "gemini-3.5-flash-lite")
BASE_URL = os.environ.get("DEMO_BASE", "https://demos-yourname.pages.dev")
client = genai.Client()

class Copy(BaseModel):
    headline: str
    subheadline: str
    about: str
    services: list[str]
    cta: str

PROMPT = """You write copy for a one-page website for a small Indonesian business.
Write in natural Bahasa Indonesia, friendly, no exaggeration.
Business: {name}
Type: {type}
Address: {address}
Google rating: {rating} from {reviews} reviews
Rules: do NOT invent prices, awards, years in business, staff names or guarantees.
Services: list 4-6 services that a typical "{type}" offers, phrased generically.
cta: a short button label for contacting them on WhatsApp."""

PAGE = """<!doctype html><html lang="id"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>{name}</title>
<style>body{{font-family:system-ui,sans-serif;margin:0;color:#1f2937}}
.demo{{background:#fef3c7;padding:8px;text-align:center;font-size:13px}}
header{{background:#0f766e;color:#fff;padding:56px 20px;text-align:center}}
section{{max-width:720px;margin:auto;padding:28px 20px}}
.btn{{display:inline-block;background:#25d366;color:#fff;padding:14px 22px;border-radius:8px;text-decoration:none;font-weight:600}}
li{{margin:6px 0}} .muted{{color:#6b7280;font-size:14px}}</style></head><body>
<div class="demo">Contoh desain untuk {name}. Tidak berafiliasi, belum dipublikasikan.</div>
<header><h1>{headline}</h1><p>{subheadline}</p>
<a class="btn" href="https://wa.me/{wa}">{cta}</a></header>
<section><h2>Tentang kami</h2><p>{about}</p>
<p><strong>&#9733; {rating}</strong> dari {reviews} ulasan Google</p></section>
<section><h2>Layanan</h2><ul>{services}</ul></section>
<section><h2>Lokasi &amp; jam buka</h2><p>{address}</p><p class="muted">{hours}</p>
<p><a href="{maps}">Buka di Google Maps</a></p></section></body></html>"""

PITCH = ("Halo kak, saya {me}, web designer di {city}. Saya lihat {name} punya {reviews} ulasan "
         "bagus di Google tapi belum punya website, jadi saya iseng buatkan contohnya: {url} "
         "Kalau suka, bisa langsung dipakai pakai domain sendiri. Kalau tidak tertarik, "
         "abaikan saja, contohnya saya hapus minggu depan. Terima kasih!")

def slug(s):
    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:50]

def main(leads_csv, me, city, limit=20):
    os.makedirs("site", exist_ok=True)
    out = []
    with open(leads_csv, encoding="utf-8") as f:
        leads = list(csv.DictReader(f))[:int(limit)]
    if not leads:
        sys.exit("No leads in CSV. Lower MIN_REVIEWS or try other queries.")
    for lead in leads:
        resp = client.models.generate_content(
            model=MODEL, contents=PROMPT.format(**lead),
            config={"response_mime_type": "application/json", "response_schema": Copy})
        c = resp.parsed
        e = {k: html.escape(str(v)) for k, v in lead.items()}
        e["hours"] = e["hours"].replace(" | ", "<br>")
        page = PAGE.format(**e, headline=html.escape(c.headline),
                           subheadline=html.escape(c.subheadline), about=html.escape(c.about),
                           cta=html.escape(c.cta),
                           services="".join(f"<li>{html.escape(s)}</li>" for s in c.services))
        s = f'{slug(lead["name"])}-{lead["place_id"][-6:].lower()}'   # suffix avoids clashes between same-name businesses
        os.makedirs(f"site/{s}", exist_ok=True)
        with open(f"site/{s}/index.html", "w", encoding="utf-8") as f:
            f.write(page)
        url = f"{BASE_URL}/{s}/"
        msg = PITCH.format(me=me, city=city, name=lead["name"], reviews=lead["reviews"], url=url)
        out.append({**lead, "demo": url,
                    "wa_link": f"https://wa.me/{lead['wa']}?text={urllib.parse.quote(msg)}"})
        print("built", url)
    with open("site/_headers", "w") as f:
        f.write("/*\n  X-Robots-Tag: noindex, nofollow\n")
    with open("outreach.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(out[0]))
        w.writeheader()
        w.writerows(out)
    print(f"{len(out)} demos -> site/, links -> outreach.csv")

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

Run it on your top 20 leads:

export DEMO_BASE="https://demos-yourname.pages.dev"
python demos.py leads.csv "Rina" "Jogja" 20

Open a few site/*/index.html files in your browser before you deploy anything. Check that the services make sense for the business. Gemini will sometimes list “cuci karpet” for a laundry that only does clothes. Fix those by hand in the HTML.

Step 3: Host all demos free on Cloudflare Pages

Use one Pages project, with each demo in its own folder. That keeps it simple and saves you from managing dozens of projects:

npx wrangler login
npx wrangler pages project create demos-yourname --production-branch main
npx wrangler pages deploy site --project-name demos-yourname

Each demo is now live at https://demos-yourname.pages.dev/<slug>/. (If you run these commands through an AI coding agent, recent Wrangler versions may route a brand-new static project to Workers instead of Pages; run them yourself in a terminal, or add --force.) The _headers file plus the noindex meta tag keep demos out of Google, which matters because you’re publishing pages with someone else’s business name on them. Run the same deploy command again whenever you add or delete folders.

Step 4: Send the pitches (manually)

Open outreach.csv. The wa_link column opens WhatsApp with the message already filled in. You still review it and press send yourself. This is deliberate: you, a human, send each message one by one from the WhatsApp Business app. No bulk sender, no unofficial API library. The Risks section explains why.

Rules that keep the number alive and the reply rate up:

  • 15–25 new contacts a day, spread out. Not 100 in an hour.
  • Send during business hours, never Friday prayer time or late evening.
  • If someone says no or doesn’t reply within 7 days, delete their demo folder and redeploy. Your message promised that, so do it.
  • When someone replies “berapa?”, send the price and a short list of what’s included. Don’t send a 3-page proposal.

What to charge

Look at what others charge before you set a price. At the time of writing, landing-page and “website UMKM” gigs on Fastwork.id mostly sit between Rp 300.000 and Rp 1.500.000, and global “one-page website” gigs on Fiverr commonly run $50–$300. Check the current listings yourself; they move.

A package that sells well because it’s simple:

ItemPrice to clientYour cost
Setup: the demo, polished, with their real photos + their own domainRp 750.000–1.250.000 one-off~1–2 hours of work
.com domain, first yearincluded~$10–11/yr at Cloudflare Registrar (at cost)
Hostingincluded$0 (Cloudflare Pages free plan)
Care plan: edits, renewal, monthly checkRp 50.000–100.000/month~15 min/month

A .my.id domain is much cheaper than .com (often tens of thousands of rupiah a year at local registrars) and fine for price-sensitive clients.

Move a paying client’s site into its own Pages project with their custom domain attached, and delete the demo banner and noindex at that point. Take a 50% deposit before you start the polish work. Most UMKM pay by bank transfer or QRIS.

Honest numbers

Costs to run 100 pitches:

ItemCost
Places API (~10–15 queries)$0 (inside the 1,000 free requests)
Gemini copy for 100 demos~$0.15
Cloudflare Pages$0
SIM card for the pitch number~Rp 25.000
Your time~1 hour to build demos and spot-check them, ~2–3 hours sending and replying

Earnings, as planning numbers rather than promises: reply and close rates for cold outreach vary a lot by city, category and how well you write. There’s no published benchmark for demo-first WhatsApp outreach to Indonesian UMKM, so treat this as a guess to test, not a forecast: out of 100 pitches, maybe 5–15 reply and 0–3 buy. At Rp 1.000.000 per setup, that’s Rp 0–3 juta per 100 pitches, plus small care-plan fees that build up slowly if clients stay. Your first 100 may well produce zero sales while you tune the category and message. Track your own numbers in outreach.csv (add replied and paid columns) and adjust after your first 100. If you’re under 1 sale per 100 after two rounds, the problem is usually the category or the message, not the volume.

The care plans are where this becomes semi-passive. As arithmetic, not a promise: twenty clients at Rp 75.000/month would be Rp 1,5 juta a month for a few hours of work. Getting to twenty retained clients takes months of steady outreach, and some will cancel.

1. Google Maps Platform terms (grey). The Places API terms don’t let you cache Places content long-term. Place IDs can be stored indefinitely, and latitude/longitude for up to 30 days. A spreadsheet of names and phone numbers kept for months goes beyond that. Practically, Google enforces this against large apps, not freelancers with a CSV of 60 rows, but you should know where the line is. To keep close to it: keep place_id as your key, delete rows you’ve finished with, and re-pull fresh data when you need it. Don’t swap the API for a Google Maps scraping tool to save $35. Scraping Maps directly breaks Google’s Terms of Service outright, and the API is free at this volume anyway.

2. Using a business’s name without permission. A demo page with their name, address and rating is a pitch, not a fake website, as long as you keep it that way: the demo banner, noindex, an unlisted URL, no domain registered in their name before they pay, and deletion on request or after 7 days. Never register namabisnis.com speculatively and then offer to “sell” it to them. That’s cybersquatting and turns a pitch into a shakedown.

3. Personal data (UU PDP). Indonesia’s personal data protection law (UU No. 27/2022) is fully in force. A phone number published on a business listing is business contact data, and a single relevant offer to that business is defensible. Spamming the same person repeatedly, selling your lead list, or messaging numbers that are clearly someone’s private phone is not. One message and one follow-up at most, and remove anyone who says stop.

4. WhatsApp bans (the real risk). WhatsApp bans numbers that many recipients block or report, and it detects automated sending. This is the most likely way this hustle hurts you. To keep the risk down:

  • Use a separate number on the WhatsApp Business app, never your personal one.
  • Warm it up for a week with normal chats before cold outreach.
  • Send manually via the click-to-chat links. Tools that automate WhatsApp Web (whatsapp-web.js, Baileys-based “blasters”, Chrome extension blasters) break WhatsApp’s terms and are the fastest route to a ban.
  • Keep messages personal and short, and include the “ignore this and I’ll delete it” line. Giving the recipient an easy way out should make them less likely to hit “report”, though nobody publishes numbers on this.
  • If you get a ban notice, request a review in the app, and stop outreach on that number for good if it comes back.

If you want volume beyond ~25/day, the compliant route is the WhatsApp Business Platform (Cloud API) with approved marketing templates, which Meta charges for per message. At that point you’re running a real sales operation, and cold marketing templates to people who never opted in still get flagged.

Reality check: what doesn’t work

  • Pitching without a demo. “Mau dibuatkan website?” gets ignored. The demo is the whole point.
  • Leads with fewer than ~30 reviews. Tiny businesses rarely pay Rp 1 juta for anything. Raise MIN_REVIEWS before you lower your price.
  • Generic AI copy. If the headline could fit any laundry in Indonesia, the owner can tell. Spend two minutes per demo adding one real detail from their Maps listing (a photo description, a menu item, a popular service mentioned in reviews).
  • Letting Gemini invent facts. “Berdiri sejak 2005” or “garansi 100%” on a demo is a lie with their name on it. The prompt forbids it; check anyway.
  • Selling “SEO” on top. A one-page site does not get them ranking #1 on Google. Their Google Business Profile does most of the local-search work. Promise a professional page and a WhatsApp button, nothing more.
  • Underpricing to Rp 200.000. You’ll get clients who expect unlimited revisions. The care plan is where the money accumulates, so don’t kill your margin on setup.
  • Forgetting renewals. If a client’s domain expires because you missed the renewal, their site goes down and so does your reputation. Put every renewal date in a calendar the day you register it.

Start today

  1. Enable Places API (New), set a $5 budget alert, and run leads.py on 4 categories in your area. (~20 minutes)
  2. Build 20 demos with demos.py, fix anything odd, deploy to Cloudflare Pages. (~45 minutes)
  3. Send 15 pitches by hand from a separate WhatsApp Business number. Log replies.
  4. Repeat for a week. You’ll know by pitch 100 whether your category and message work.

The code is about 150 lines, the API bill is cents, and one sale covers a year of domain and API costs many times over. What’s left is sending messages carefully, and that’s the part most people skip.