The warung owner’s phone buzzes all day. “Kak, nasi ayam sambal matah 2, es teh 3, antar ke…” — and every message needs reading, adding up, confirming, and typing into a notebook or a sheet. At 40 orders a day that’s hours of thumb-work, and every misread quantity is a free drink or an angry customer.

So people ask for a “WhatsApp bot”. This guide covers what a realistic one looks like in 2026: what you can and can’t legally automate, an architecture that won’t get the shop’s number banned, the Python that does the parsing for about $0.002 per order, and — since you’re probably building this for others — how to price it.

The hard constraint first: what you can’t do

Let’s kill the fantasy before the code, because it saves everyone months.

Unofficial automation gets numbers banned. Libraries that drive WhatsApp through reverse-engineered protocols (Baileys, whatsapp-web.js, and every “free WhatsApp bot” built on them) violate WhatsApp’s terms. WhatsApp detects them, and for a business account the punishment isn’t a warning — it’s the number. A warung’s WhatsApp number is the business. Losing it to save an hour a day is a trade no owner would make knowingly, yet most “WA bot” tutorials never mention it.

The official route exists but is heavy. The WhatsApp Business Platform (Cloud API) is Meta’s sanctioned automation path. Per-message pricing, business verification, a webhook server, template messages with pre-approval. It works, but for a warung taking 40 orders a day, the setup cost and compliance overhead usually outweigh the benefit. It’s the right answer for a business doing hundreds of conversations daily, not for a one-location shop.

So what’s left is the architecture that actually fits a warung, and it works better than most people expect.

The architecture that fits: human in the loop, machine on the typing

The shop keeps replying by hand (which customers prefer anyway — it feels personal). What gets automated is the back office: turning messy order messages into a clean, totalled, checked order sheet and a ready-to-paste confirmation.

customer messages  ──►  owner/shop admin replies as usual
        │
        └─► order messages pasted once into chats.txt (or a shared note)
                    │
                    ▼
        orders.py  (Python + Gemini, paid tier)
                    │  parses items vs. catalog, never does the maths itself
                    ▼
        orders.csv ──► packing + delivery sheet
        confirmations.txt ──► paste-ready per-customer replies

One paste-batch per hour or per shift, one run of the script, and the sheet is current. The owner’s thumbs are freed; the number stays safe; nothing ships without a human having seen the REVIEW rows.

This is deliberately the same pattern that works for order sheets at scale — because it’s the one part of the “WA bot” dream that’s both legal and robust.

What you need

ItemCostNotes
Python 3.10+$0The shop’s laptop, or a $5 VPS
Gemini API key$0 to startaistudio.google.com → “Get API key”
google-genai, pydantic$0pip install
The shop’s menu as a CSV$0The heart of the system — see below
WhatsApp (regular app)$0No Business API needed for this pattern

Pricing as of October 2026 — verify at ai.google.dev/pricing:

  • Gemini 3.5 Flash-Lite (paid tier): $0.30 per 1M input tokens, $2.50 per 1M output tokens
  • Free tier: fine for testing with fake orders. Real customer names, phones and addresses go through the paid tier only — free-tier content can be used to improve Google’s products.

Step 1: The menu file

The catalog decides everything. The model never invents a dish — it must match against this list, and Python verifies the match:

menu.csv:

sku,name,price,aliases
MKN-01,Nasi Ayam Sambal Matah,18000,nasi ayam; ayam sambal matah; NASAY
MKN-02,Nasi Goreng Spesial,15000,nasgor spesial; nasgor; nasi goreng
MKN-03,Mie Goreng,13000,mie goreng; migoreng
MNM-01,Es Teh Manis,5000,es teh; teh manis; esteh
MNM-02,Es Jeruk,6000,es jeruk; peras jeruk; esjeruk
MNM-03,Kopi Susu,8000,kopsus; kopi susu gula aren

The aliases column is where the work is. Pull up a week of real order messages and write down every way customers actually say each item — typos included (“migoreng”, “esteh”). This column determines how many orders parse cleanly, far more than the model choice does. Ten minutes here saves an hour of corrections later.

Step 2: The script

Order messages get pasted into chats.txt, separated by a --- line. Save as orders.py:

import argparse, csv, os, re, sys, time
from pathlib import Path

from google import genai
from google.genai import errors, types
from pydantic import BaseModel

MODEL = os.environ.get("ORDER_MODEL", "gemini-3.5-flash-lite")
MAX_QTY = 30          # one order rarely exceeds this at a warung

class Item(BaseModel):
    sku: str
    qty: int
    note: str         # "pedas", "tanpa bawang", etc.

class Order(BaseModel):
    customer_name: str
    phone: str
    address: str      # empty for pickup orders
    fulfilment: str   # "delivery" or "pickup"
    items: list[Item]
    unclear: list[str]

def build_prompt(menu_text, chat):
    return f"""You extract food orders from Indonesian WhatsApp messages for a small restaurant.
Rules:
- Only use SKUs from the menu below. Match by name and aliases.
- If an item can't be matched to exactly one SKU, do not guess. Put the customer's words in "unclear".
- Copy customer name, phone, address exactly as written. If missing, leave empty and add
  "missing name" / "missing phone" / "missing address" to "unclear".
- Never invent or complete a phone number or address.
- qty is the number of portions; if not stated, use 1.
- fulfilment: "delivery" if delivery is mentioned or implied, else "pickup".
- note: cooking instructions the SKU doesn't cover, otherwise empty.

Menu (SKU | name | price | aliases):
{menu_text}

Chat:
{chat}"""

def extract(prompt):
    for attempt in range(3):
        try:
            resp = client.models.generate_content(
                model=MODEL, contents=prompt,
                config=types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=Order,
                    thinking_config=types.ThinkingConfig(thinking_level="minimal"),
                ),
            )
        except errors.APIError as e:
            print(f"  API error {e.code} (attempt {attempt + 1})", file=sys.stderr)
            time.sleep(5 * (attempt + 1))
            continue
        if resp.parsed:
            return resp.parsed
    return None

def check(order, chat, menu):
    problems = list(order.unclear)
    squeezed = re.sub(r"[\s\-.()+]", "", chat)
    phone = re.sub(r"\D", "", order.phone)
    if order.fulfilment == "delivery":
        if len(phone) < 9 or phone[-9:] not in squeezed:
            problems.append("phone missing or not found in chat")
        if len(order.address) < 15:
            problems.append("address looks incomplete")
    if not order.items:
        problems.append("no items")
    subtotal, lines = 0, []
    for it in order.items:
        sku = it.sku.strip().upper()
        if sku not in menu:
            problems.append(f"unknown SKU {it.sku}")
            continue
        if not 1 <= it.qty <= MAX_QTY:
            problems.append(f"check qty {it.qty} for {sku}")
        price = int(menu[sku]["price"])
        subtotal += price * it.qty            # Python does ALL the maths
        lines.append((sku, menu[sku]["name"], it.qty, price, it.note))
    return problems, subtotal, lines

def rupiah(n):
    return "Rp" + f"{n:,}".replace(",", ".")

def confirmation(order, lines, subtotal):
    items = "\n".join(f"- {name} x{qty} = {rupiah(price*qty)}" + (f" ({note})" if note else "")
                      for _, name, qty, price, note in lines)
    where = f"Antar ke: {order.address}" if order.fulfilment == "delivery" else "Diambil di tempat"
    return (f"Halo kak {order.customer_name}, pesanannya:\n{items}\n"
            f"Total: {rupiah(subtotal)}\n{where}\n"
            f"Mohon dicek ya kak, langsung kami proses :)")

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("chats")
    ap.add_argument("--menu", default="menu.csv")
    ap.add_argument("--out", default="orders.csv")
    args = ap.parse_args()

    client = genai.Client()   # reads GEMINI_API_KEY
    with open(args.menu, newline="", encoding="utf-8") as f:
        menu = {r["sku"].strip().upper(): r for r in csv.DictReader(f)}
    menu_text = "\n".join(f'{s} | {r["name"]} | {r.get("aliases","")}' for s, r in menu.items())

    text = Path(args.chats).read_text(encoding="utf-8")
    chats = [b.strip() for b in re.split(r"^\s*---\s*$", text, flags=re.M) if b.strip()]

    rows, replies, tin, tout = [], [], 0, 0
    for n, chat in enumerate(chats, 1):
        order = extract(build_prompt(menu_text, chat))
        if order is None:
            rows.append({"no": n, "status": "REVIEW", "problems": "API failed", "chat": chat})
            continue
        u = None
        problems, subtotal, lines = check(order, chat, menu)
        status = "REVIEW" if problems else "OK"
        rows.append({"no": n, "status": status, "name": order.customer_name,
                     "phone": re.sub(r"\D", "", order.phone), "address": order.address,
                     "fulfilment": order.fulfilment,
                     "items": "; ".join(f"{sku} x{qty}" for sku, _, qty, _, _ in lines),
                     "total": subtotal, "problems": "; ".join(problems), "chat": chat})
        if status == "OK":
            replies.append(f"#{n} {order.customer_name}\n{confirmation(order, lines, subtotal)}\n")
        print(f"#{n}: {status} {'; '.join(problems)}")

    with open(args.out, "w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=rows[0].keys())
        w.writeheader()
        w.writerows(rows)
    Path("confirmations.txt").write_text("\n".join(replies), encoding="utf-8")
    ok = sum(r["status"] == "OK" for r in rows)
    print(f"\n{ok}/{len(rows)} OK, rest need review. See {args.out} and confirmations.txt")

if __name__ == "__main__":
    main()
python orders.py chats.txt --menu menu.csv

Every OK row has a total computed by Python from the menu’s real prices — the AI never sees a price, so it can never sum wrong. Every REVIEW row gets handled by the owner in ten seconds instead of becoming a wrong delivery.

Honest cost breakdown

BeforeAfter
Reading + totalling 40 orders~60–90 min/day~15 min (paste + review rows)
Order lognotebook / memoryorders.csv, dateable and searchable
Confirmation messagestyped by hand each timepaste-ready, correct totals
Wrong deliveries from misreadsoccasionalcaught at parse time

Cost side: at 1,200 orders/month with a 20-item menu, the API bill is roughly $2–3/month on the paid tier. The software is free. The machine running it is one they already own.

How to Sell This as a Service

You’re not selling a bot. You’re selling “your order notebook does itself” — and the demo sells it in five minutes.

The offer:

  1. Setup (one-off). You build menu.csv from their real menu plus an hour of reading their actual order chats for aliases, install the script, run it on yesterday’s orders live, and train whoever handles the phone. Two to four hours of your work.
  2. Monthly care (recurring). New menu items added to the CSV, review-pile patterns checked, monthly order-summary report (best sellers, peak hours) — a report they currently can’t produce at all. This is the retainer that makes the business worth having.

Pricing. Anchor on their time, not your costs. An hour a day of the owner’s or a staff member’s time is worth far more monthly than your setup hours. As a test zone for an Indonesian warung/small restaurant: Rp 500,000–1,500,000 one-off setup plus Rp 200,000–500,000/month for menu updates and the monthly summary. Validate against what a part-time helper costs locally and price clearly below that. Their Gemini key, their bill, their data.

Finding clients: every warung, katering, frozen-food seller and small restaurant you already buy from. Show the script eating their real (permissioned) order chats and printing a clean sheet. Katering is a sweet spot — order counts are high and every order is complex.

Never promise: a fully automatic reply bot. Promise “orders become a clean sheet with correct totals in minutes, and you keep replying personally”. The first version gets you banned numbers and refunds; the second gets you retainers.

Reality check: what does NOT work

  • Baileys / whatsapp-web.js / “free WA bot” stacks. Bannable, and the ban hits the business’s number. The economics of a warung can’t survive losing it.
  • Voice notes and photos of handwritten lists. The script reads text. Customers who order by voice note need a human (or a separate transcription step).
  • Payment confirmation by screenshot. Edited transfer screenshots are a common scam. Money is verified in the bank/e-wallet app, never by AI.
  • Sending the menu on every call forever. Fine under ~50 items. Past a few hundred, filter the menu to candidate items before calling the model.
  • Customer data hygiene. Names, phones, addresses are personal data under Indonesia’s PDP Law (UU No. 27/2022). Paid tier only, delete old chats files, don’t paste orders into random free web tools.
  • Skipping the alias work. “The AI got it wrong” almost always means “the menu aliases are thin”. Fix the CSV, not the prompt.

Final checklist

  • menu.csv built from real chats, aliases from how customers actually write
  • Paid-tier key, budget alert set in Google Cloud Billing
  • Tested on 20+ real orders, every OK row spot-checked for a week
  • No unofficial WhatsApp automation anywhere in the flow
  • Money confirmed in the bank app, never in the script
  • Old chat files deleted once orders are delivered

Set it up for one shop — yours or one you know — and run it alongside the notebook for a week. When the sheet is more current than the notebook, you have both a working system and your demo.