By the end of this tutorial, you will have a short Python script that turns a keyword and a Vietnamese city into rows of Zalo Official Accounts: business name, category in Vietnamese and English, verification state, phone, address, working hours and website.
Zalo Official Accounts (OAs) are the business pages on Zalo, a Vietnamese messaging platform. Each has a public page at zalo.me/<id>, but zalo.me has no OA directory or search. The Actor finds them through Google and then reads each page.
In this guide, we use Python and the Apify Actor Zalo Official Account Finder (reventadata/zalo-scraper). You will configure a search, run it and filter rows by phone, verification and category. The sample row below is a sample from a capture dated 2026-10-12, parsed with the Actor's own parser.
⏱️ Time estimate: one sitting: install one package, set one token, run three short Python blocks.
Prerequisites
To follow this tutorial, you will need:
-
Python 3.11+ (
apify-client3.3.0 requires it). - The Apify API client for Python.
- An Apify account and its API token, which sits on the API & Integrations page in Apify Console.
Install the client:
pip install "apify-client~=3.0"
The code reads the token from an environment variable:
export APIFY_API_TOKEN="your_apify_token_here"
What you'll achieve
You will turn a Vietnamese keyword, optionally combined with cities or provinces, into one row per Official Account. Each row carries oaId, name, category and categoryEn, verified, tier, description, address, phone, workingHours, website, the avatar and QR code URLs, the canonical url, and matchedQuery plus discovery, which say how the account was found.
Note: Per its README, the Actor reads public OA pages only. It does not access personal profiles, groups, conversations or login-only content, and rows describe businesses, not individuals.
Example use case: Suppose you want verified spas in Hà Nội that publish a phone number. You would set searchQueries: ["spa"], locations: ["Hà Nội"] and onlyWithPhone: true. With the default verifiedOnly: true, every saved row would be a verified account with a phone.
Step 1: Access Zalo Official Account Finder
Go to the Zalo Official Account Finder page on Apify Store. The README lists the inputs and output fields. In this tutorial we drive the Actor from Python, so everything after this step happens in code.
Step 2: Configure the search
There are two ways in, and you can use both in one run:
-
searchQueries: Vietnamese keywords, such asspaornhà hàng(restaurant). Each one becomes a Google search for zalo.me pages. -
locations: Vietnamese cities or provinces, such asHà Nội. Each query is combined with each location; leave it empty to search the query alone. -
oaUrls: accounts you already know, as zalo.me URLs, numeric OA ids or slugs (for examplezalopay). These are fetched first.
For each query and location, the Actor searches Google for site:zalo.me "Thông tin chi tiết" <query> <location> in Vietnamese, 10 results per page. maxPagesPerQuery (1 to 10, default 3) bounds the pages per combination, and maxItems (default 100) caps the rows saved.
Here is the setup. It is the Actor's own prefill: spa in Hà Nội, one Google page, at most 5 rows:
import os
from apify_client import ApifyClient
# 1. Initialize ApifyClient with the token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])
# 2. Define the search: one keyword, one city, one Google page, at most 5 rows
run_input = {
"searchQueries": ["spa"],
"locations": ["Hà Nội"],
"maxPagesPerQuery": 1,
"maxItems": 5,
}
💡 Tip: discovery covers only what Google has indexed, per the README. If a business you know is missing, put its zalo.me link in oaUrls.
Step 3: Add optional filters
The filters run on each account after its page is read. Rows a filter drops never reach your dataset:
| Parameter | Type | What it keeps |
|---|---|---|
verifiedOnly |
boolean | Accounts Zalo marks as verified. On by default. |
onlyWithPhone |
boolean | Accounts whose page shows a phone with at least 7 digits. |
categoryContains |
string | Accounts whose Vietnamese or English category contains the text, ignoring case and accents. |
debugSaveSerp |
boolean | Not a filter: saves the first raw Google results page under the key SERP_SAMPLE. |
Three combinations that work:
-
Restaurants in one city:
searchQueries: ["nhà hàng"],locations: ["Hà Nội"],categoryContains: "restaurant". -
Callable businesses only: any search plus
onlyWithPhone: true. -
Known accounts, verified or not:
oaUrlswith your list andverifiedOnly: false.
⚠️ An input with neither
searchQueriesnoroaUrlsstops with an error at the start of the run. AmaxPagesPerQueryoutside 1 to 10 is refused before the run starts.
Step 4: Run the Actor
Now start the run with client.actor("reventadata/zalo-scraper").call(run_input=run_input). The call starts the Actor on Apify and waits for it to finish before returning the run:
# 3. Start the Actor run and wait for it to finish
print("Starting Zalo Official Account discovery...")
run = client.actor("reventadata/zalo-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
status = run.status if run else "unknown"
raise SystemExit(f"Run did not succeed (status: {status}).")
# 4. Fetch the saved accounts
items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} Official Accounts.\n")
Behind the scenes:
- The Actor fetches any
oaUrlsfirst, then reads Google's results pages for each query and location through Apify's Google SERP proxy, with fallbacks. - It fetches each OA page through Apify Unblocker. In testing on 2026-10-12, zalo.me was unreachable from Apify with no proxy, datacenter or residential proxies; Unblocker reached it.
- It decodes the account data embedded in the page, applies your filters, and saves the rows. An account found by two queries is fetched once.
Step 5: View and download results
Loop over the rows to print the parts you need:
for item in items:
print(item["name"])
print(f" Category: {item.get('categoryEn') or item.get('category')}")
print(f" Phone: {item.get('phone') or 'not shown'}")
print(f" Address: {item.get('address') or 'not shown'}")
print(f" Found by: {item.get('discovery')} {item.get('matchedQuery') or ''}")
print(f" URL: {item['url']}")
print("-" * 40)
Here is one row, a sample from a capture dated 2026-10-12, parsed with the Actor's own parser. It is not from the search above: the page was parsed as a direct oaUrls lookup, so matchedQuery is null and discovery is direct. A row found by the search would carry matchedQuery: "spa Hà Nội" and discovery: "google". A saved row also carries scrapedAt, a UTC timestamp:
{
"oaId": "182558433991193310",
"name": "Nhà hàng Nhật Bản Takumi",
"category": "Nhà hàng & Quán",
"categoryEn": "Local restaurant",
"verified": true,
"tier": 1,
"description": "Nhà hàng Nhật Bản Takumi là một trong những nhà hàng Nhật Bản uy tín và được yêu thích bởi các thực khách yêu thích ẩm thực xứ hoa anh đào tại Hà Nội. Với 2 nhà hàng được thiết kế không gian tinh tế với quầy Sushi bar, phòng Tatami kiểu Nhật… Cùng phong cách phục vụ chu đáo và đặc biệt tất cả các món ăn đều mang đậm hương vị của ẩm thực Nhật Bản. Đặc biệt là nhà hàng duy nhất tại Việt Nam Takumi đưa món Thịt Bò Thượng Hạng Hida (một loại bò đen nổi tiếng tại Nhật) vào menu của mình ....",
"address": "98 Mai Hắc Đế, Quận Hai Bà Trưng, Hà Nội",
"phone": "0936868080",
"workingHours": "10:00 - 22:00",
"website": "https://takumi.com.vn/",
"avatarUrl": "https://s160-26-ava-talk.zadn.vn/1/d80140d4ce7c41bc3da0a8e16bca6d18.jpg?key=RokQoWhvS9SmjaNXbGl73A&time=1796974633",
"qrCodeUrl": "https://page-photo-qr.zdn.vn/1679291835/1b4ad2cdc6882fd67699.jpg",
"url": "https://zalo.me/182558433991193310",
"matchedQuery": null,
"discovery": "direct"
}
Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.
Understanding your results
The fields that matter most:
-
verifiedandtier: Zalo's verification mark and account tier. All 4 OA pages in the 2026-10-12 capture were verified, tier 1. -
phone,address,workingHours,website: the business details block, null when the page has none. In the capture, 3 of 4 accounts showed a phone, an address and working hours, and 2 of 4 a website. One restaurant showed none of the four. -
categoryandcategoryEn: Zalo's category, in Vietnamese and English. They can surprise you: in the capture,NHÀ HÀNG BẢN SÓC(nhà hàng means restaurant) is filed underThực phẩm & Đồ uống/Grocery. -
url: always the numeric form. Thezalopayslug in the capture came back ashttps://zalo.me/4046405574025332160. -
discovery:googlefor search results,directforoaUrls, orfallback-seed(see Pro tips). -
matchedQuery: the query and location that found the row, null for direct lookups.
Pro tips
-
Check
discoverybefore you count. If every transport fails on the first Google page of the first query, and there are nooaUrlsand no rows yet, the Actor saves up to ten previously captured accounts flaggedfallback-seed, with a nullmatchedQuery. Drop them when you need search results only. -
Type the letter đ. Accents are ignored, but đ is its own letter:
do uongdoes not matchĐồ uống, whileđồ uốngdoes. -
Match both languages.
categoryContains: "restaurant"matchesLocal restaurant;nha hangmatchesNhà hàng & Quán. Neither matched the restaurant filed underGroceryin the capture. -
Batch cities in one run. Several
locationsmultiply each query. An account kept under one combination is not saved again under the next. -
Start small. Keep
maxPagesPerQuery: 1andmaxItems: 5until your code handles the fields, then raise them. Each extra Google page adds up to 10 more results to read.
Troubleshooting
-
Run FAILED with zero rows: a run that is blocked, cannot read a page, gets bad input or has a search return no OA links ends
FAILED; if pages load normally but filters drop every account, the run endsSUCCEEDEDwith an empty dataset and a status message saying so. -
A pasted link was skipped:
oaUrlsrejects links with a query string (such as?src=qr), group links (/g/...) and numeric ids under 10 digits. Strip the query string. -
OA not found: the page redirected to Zalo's login. Personal profile links (zalo.me/<phone>) do that, per the Actor's recon. -
Fewer rows than expected: the log has one
Rejected N: <reason>line per reason. From Python:client.log(run.id).get().
For help, use the Actor's Store page.
Pricing and next steps
For current pricing and details, see the Store page.
To repeat a search, save the input as a task and add an Apify schedule: it runs on a cron expression (0 8 * * * is every day at 8 AM). To react to a finished run, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId. Before reusing contact details, check Vietnam Law 91/2025/QH15 (README).
Which businesses or cities are you planning to look up on Zalo? Drop a comment! 💬
Top comments (0)