DEV Community

Cover image for How to Find Verified Zalo Official Accounts in Vietnam with Python
ReventaData
ReventaData

Posted on Edited on Originally published at apify.com

How to Find Verified Zalo Official Accounts in Vietnam with Python

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:

  1. Python 3.11+ (apify-client 3.3.0 requires it).
  2. The Apify API client for Python.
  3. 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"
Enter fullscreen mode Exit fullscreen mode

The code reads the token from an environment variable:

export APIFY_API_TOKEN="your_apify_token_here"
Enter fullscreen mode Exit fullscreen mode

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 as spa or nhà hàng (restaurant). Each one becomes a Google search for zalo.me pages.
  • locations: Vietnamese cities or provinces, such as Hà 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 example zalopay). 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,
}
Enter fullscreen mode Exit fullscreen mode

💡 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: oaUrls with your list and verifiedOnly: false.

⚠️ An input with neither searchQueries nor oaUrls stops with an error at the start of the run. A maxPagesPerQuery outside 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")
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  • The Actor fetches any oaUrls first, 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)
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.


Understanding your results

The fields that matter most:

  • verified and tier: 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.
  • category and categoryEn: 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 under Thực phẩm & Đồ uống / Grocery.
  • url: always the numeric form. The zalopay slug in the capture came back as https://zalo.me/4046405574025332160.
  • discovery: google for search results, direct for oaUrls, or fallback-seed (see Pro tips).
  • matchedQuery: the query and location that found the row, null for direct lookups.

Pro tips

  1. Check discovery before you count. If every transport fails on the first Google page of the first query, and there are no oaUrls and no rows yet, the Actor saves up to ten previously captured accounts flagged fallback-seed, with a null matchedQuery. Drop them when you need search results only.
  2. Type the letter đ. Accents are ignored, but đ is its own letter: do uong does not match Đồ uống, while đồ uống does.
  3. Match both languages. categoryContains: "restaurant" matches Local restaurant; nha hang matches Nhà hàng & Quán. Neither matched the restaurant filed under Grocery in the capture.
  4. Batch cities in one run. Several locations multiply each query. An account kept under one combination is not saved again under the next.
  5. Start small. Keep maxPagesPerQuery: 1 and maxItems: 5 until 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 ends SUCCEEDED with an empty dataset and a status message saying so.
  • A pasted link was skipped: oaUrls rejects 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)