Lemon8 has no public posting API in October 2026. Every post goes through the mobile app: tap the plus button, pick photos from the gallery, choose a template, type a headline and a description, put the hashtags at the end, publish. For one account that takes two minutes. For ten accounts every day, somebody on your team loses a good part of each morning tapping through the same five screens.
Appium can do the tapping. The harder part is where the ten phones live, because a Lemon8 account needs a device that stays signed in, keeps its own network identity and still answers to an automation server next month. Below is the setup I'd use: one Appium 3 server, ten BitCloudPhone Android phones reached over ADB, a human approval step before anything goes live, and a pacing plan that keeps each account behaving like what it is.
Who this is for: social teams and agencies that run several Lemon8 accounts they own or manage for clients. If the plan is fake engagement or a ring of throwaway accounts, stop here. Lemon8 bans that, and no device setup changes the outcome.
Table of contents
- Why Lemon8 still needs a phone
- What Lemon8 lets you automate
- Three ways to host ten phones
- Where iOS cloud phones fit
- The architecture
- Prepare the ten phones
- Build the content queue
- Write the posting script
- Run all ten with pacing
- Logs and failure rules
- The browser side of each account
- Common errors
- FAQ
Why Lemon8 still needs a phone
Lemon8 is ByteDance's photo-and-text discovery app, somewhere between Pinterest and Instagram. A data.ai estimate put it at about 23.4 million monthly active users in March 2026, and paid ads now run in six markets: the US, Japan, Thailand, Singapore, Malaysia and Indonesia. Since 22 January 2026 the US side has been run by the TikTok USDS Joint Venture, and the US supplemental terms that took effect that day say every Lemon8 profile has to be linked to a TikTok account.
Three facts shape the automation work. Posting happens in the app: there is no public posting API and no documented partner tool for publishing. Each US account carries a TikTok link, so ten Lemon8 accounts means ten TikTok accounts behind them, and the guidelines warn that penalties can apply on both apps when you sign in to both with the same account. And the format is vertical (3:4 photos, a headline, a long description with the hashtags inside it).
So the job is UI automation on a phone. On Android that means Appium with the UiAutomator2 driver, pointed at the Lemon8 package, which is com.bd.nproject.
What Lemon8 lets you automate
Lemon8's community guidelines don't set a number of accounts per person. What they ban is plain: activity that inflates popularity artificially, buying or selling likes and followers, promoting fake-traffic services, using automated scripts to collect information from Lemon8, and getting around measures that restrict access. The supplemental terms add inauthentic commercial behavior such as spam accounts. One severe violation, or several smaller ones, can get an account removed.
That splits the work cleanly:
| Task | Automate it? | Reason |
|---|---|---|
| Upload an approved post (photos, headline, description) | Yes | Same taps a person makes, on content you own |
| Choose the posting time | Yes | A publishing decision, not engagement |
| Like, follow, comment, save | No | This is the center of the fake-engagement ban |
| Read feeds, collect profiles or stats | No | The guidelines name automated data collection directly |
| Sign in, pass a verification, answer a security prompt | No | A person handles these, every time |
| Write the post | Human, or human-approved | Near-identical captions across accounts look like spam |
Everything in this article automates the first two rows and nothing else.
Three ways to host ten phones
You need ten Android devices that stay signed in, each with its own IP, all reachable by adb. Teams usually end up with one of three setups.
| Option | Keeps Lemon8 signed in | Own IP per device | Appium access | Upkeep |
|---|---|---|---|---|
| Ten physical phones on a USB hub | Yes | Needs ten SIMs or a proxy app on each | Yes, over USB | Cables, batteries, OS updates, a desk |
| Test device farms (BrowserStack, AWS Device Farm and similar) | No, devices are cleaned after a session | Provider IPs | Yes, it's what they are built for | Low, billed per test minute |
| Persistent Android cloud phones | Yes | Proxy set per device | Yes, when the provider opens ADB | Low |
Device farms are excellent for testing an app and wrong for running accounts. The device you get tomorrow is a clean one, so every session starts at the login screen. Physical phones work, and plenty of teams start there, but ten handsets turn into a small hardware project with its own failure modes.
For this job I'd pick BitCloudPhone Android devices. Each phone keeps its state between sessions and takes its own proxy. Language, time zone and location follow that IP, and ADB and root can be switched on per device. It also has an API for creating and starting phones, uploading files and installing apps, which pays off once the pool grows past ten.

There's a built-in RPA recorder as well (added in May 2026, on Computing Power 12 and 15 devices) that replays recorded taps on a schedule, even with your own computer switched off. That covers simple flows. Appium is the better fit when you want the logic in code, under version control, with waits and checks you can test.
Where iOS cloud phones fit
Appium can drive iPhones too, through its XCUITest driver. The catch is WebDriverAgent: a small server that has to be built and signed with Xcode, installed on the device and reachable from your Appium machine (it listens on port 8100 by default). Test farms set that up for you. Cloud iPhones sold for account work usually don't.
BitCloudPhone-iOS, launched on 11 July 2026, is the kind built for accounts. You get a native iOS environment on iPhone SE hardware (an SE2 or SE3 is assigned when the device is created), a SOCKS5 proxy per phone that you set before first boot, App Store installs, file upload, and a QR handoff that moves a session to a physical iPhone. As of October 2026 its documentation describes no WebDriverAgent endpoint, API or Appium support for the iOS phones.
In this setup, then, the iOS devices hold the accounts a person runs by hand. Think of a creator who shoots and edits on an iPhone and wants to post from iOS, or a client account where someone answers comments and messages manually. Same client, same proxy rules, no script.
Before planning automation on any iOS cloud phone, run this check:
# Ask the provider for the WebDriverAgent address, then: curl -s http://<wda-host>:8100/status # A JSON reply containing "ready": true means the XCUITest driver can attach. # No address, or no reply, means the phone is manual-only for now. The architecture
+------------------------------+ | Controller (Mac/Win/Linux) | | Appium 3 + UiAutomator2 | | run_all.py + approved/ | +--------------+---------------+ | adb over TCP +---------+---------+---+-----+---------+---------+ | phone01 | phone02 | ... | phone09 | phone10 | | SOCKS5 | SOCKS5 | | SOCKS5 | SOCKS5 | | US-NY | US-CA | | SG | TH | +---------+---------+---------+---------+---------+ one Lemon8 account per phone, each with its TikTok link One Appium server can hold all ten sessions at once. Two capabilities keep them apart: appium:udid says which device, and appium:systemPort sets the local port the UiAutomator2 server on that device is forwarded to (8200 by default). Give each phone its own systemPort or parallel sessions collide.
Proxies live on the phones, not in Appium. The controller never touches network identity, so a bug in your script can't route phone03's traffic through phone07's IP.
Prepare the ten phones
Do this once per phone, by hand:
- Create the phone in the BitBrowser client (cloud phones are managed from its Phone Profiles section) and assign a SOCKS5 proxy in the region the account posts for.
- Install Lemon8 on the phone and turn off auto-update for it. A new app version in the middle of a run is the most common way locators break.
- Sign in to the Lemon8 account and its linked TikTok account yourself. Finish any verification yourself too.
- Turn on ADB for the phone and note the address you'll connect to.
Then set up the controller:
# Appium 3 needs Node.js ^20.19.0, ^22.12.0 or >=24, plus npm 10+ node -v && npm -v npm install -g appium appium driver install uiautomator2 appium driver doctor uiautomator2 # runs the driver's environment checks pip install Appium-Python-Client # Connect each phone with the ADB address you noted adb connect 203.0.113.11:5555 adb devices -l UiAutomator2 driver 5.x works only with Appium 3, so upgrade both together. Start the server with the one insecure feature the script uses (shell access, needed to refresh the gallery after photos are pushed):
appium --allow-insecure uiautomator2:adb_shell Appium 3 wants the driver prefix on that flag. The bare adb_shell form from Appium 2 is no longer accepted.
Last, a device map. One line per phone:
[ {"account": "brand-a-us-east", "serial": "203.0.113.11:5555", "system_port": 8201, "tz": "America/New_York"}, {"account": "brand-a-us-west", "serial": "203.0.113.12:5555", "system_port": 8202, "tz": "America/Los_Angeles"}, {"account": "brand-b-sg", "serial": "203.0.113.19:5555", "system_port": 8209, "tz": "Asia/Singapore"}, {"account": "brand-b-th", "serial": "203.0.113.20:5555", "system_port": 8210, "tz": "Asia/Bangkok"} ] Build the content queue
Posts sit in folders. A person writes or approves each one, then moves it from queue/ to approved/. The script only ever reads approved/, and it moves a post to posted/ once it's live.
queue/ brand-a-us-east/ 2026-10-14-autumn-tableware/ 01.jpg 02.jpg 03.jpg post.json approved/ brand-a-us-east/ ... posted/ brand-a-us-east/ ... Each post.json holds the text:
{ "headline": "Three tablescapes for a small apartment", "description": "Plates from our autumn line on a 70 cm table. Swipe for the linen version. #tablescape #smallapartment #autumndecor", "images": ["01.jpg", "02.jpg", "03.jpg"] } Three rules for the queue. Images are 3:4. No caption or image appears on two accounts. And nothing reaches approved/ without a person reading it on a phone-sized screen first, because a headline that looks fine in a JSON file can get cut off in the feed.
Write the posting script
Lemon8 changes its interface without notice, so don't hard-code element IDs. Open Appium Inspector against one phone, walk through a post by hand, and copy each element's locator into a file:
{ "create_button": ["accessibility id", "FILL_ME"], "gallery_items": ["id", "com.bd.nproject:id/FILL_ME"], "next_button": ["id", "com.bd.nproject:id/FILL_ME"], "headline_field": ["id", "com.bd.nproject:id/FILL_ME"], "description_field": ["id", "com.bd.nproject:id/FILL_ME"], "post_button": ["id", "com.bd.nproject:id/FILL_ME"] } post_one.py pushes the photos, opens Lemon8, fills the post and stops before the final tap unless you switch dry-run off:
import json import random import time from pathlib import Path from appium import webdriver from appium.options.android import UiAutomator2Options from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait LEMON8 = "com.bd.nproject" REMOTE_DIR = "/sdcard/Pictures/lemon8-queue" LOC = json.loads(Path("locators.json").read_text()) def by(key): strategy, value = LOC[key] return (strategy, value) def pause(low=1.2, high=3.5): time.sleep(random.uniform(low, high)) def start_session(device): opts = UiAutomator2Options() opts.set_capability("appium:udid", device["serial"]) opts.set_capability("appium:systemPort", device["system_port"]) opts.set_capability("appium:noReset", True) opts.set_capability("appium:newCommandTimeout", 300) return webdriver.Remote("http://127.0.0.1:4723", options=opts) def push_images(driver, post_dir, images): for name in images: remote = f"{REMOTE_DIR}/{post_dir.name}-{name}" driver.push_file(remote, source_path=str(post_dir / name)) # Deprecated since Android 10 but still honored by many builds. driver.execute_script("mobile: shell", { "command": "am", "args": ["broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", f"file://{remote}"], }) def post(device, post_dir, dry_run=True, shots=Path("shots")): meta = json.loads((post_dir / "post.json").read_text()) shots.mkdir(exist_ok=True) tag = f'{device["account"]}-{post_dir.name}' driver = start_session(device) wait = WebDriverWait(driver, 20) try: push_images(driver, post_dir, meta["images"]) driver.activate_app(LEMON8) pause(3, 6) wait.until(EC.element_to_be_clickable(by("create_button"))).click() pause() thumbs = wait.until(EC.presence_of_all_elements_located(by("gallery_items"))) for thumb in thumbs[:len(meta["images"])]: # newest photos first thumb.click() pause(0.6, 1.4) # Gallery -> template screen -> details. Match this to your app version. for _ in range(2): wait.until(EC.element_to_be_clickable(by("next_button"))).click() pause() wait.until(EC.element_to_be_clickable(by("headline_field"))).send_keys(meta["headline"]) pause() wait.until(EC.element_to_be_clickable(by("description_field"))).send_keys(meta["description"]) pause() driver.get_screenshot_as_file(str(shots / f"{tag}-before-post.png")) if dry_run: return "dry-run: stopped before Post" wait.until(EC.element_to_be_clickable(by("post_button"))).click() pause(5, 8) driver.get_screenshot_as_file(str(shots / f"{tag}-after-post.png")) return "posted" finally: driver.quit() A few choices in there are deliberate. noReset keeps the phone's sign-in between sessions. The random pauses aren't there to trick anything; they keep the script from firing taps faster than the app's own animations finish, which is the usual cause of flaky clicks. The gallery picks the newest photos, which are the ones just pushed, but confirm the order on your Lemon8 version during the dry runs.
Run all ten with pacing
run_all.py takes the oldest approved post for each account, runs three phones at a time, spreads the start times and logs every result:
import json import os import random import shutil import time from concurrent.futures import ThreadPoolExecutor from datetime import datetime from pathlib import Path from post_one import post DEVICES = json.loads(Path("devices.json").read_text()) GROUP = os.environ.get("TZ_GROUP") # run one time zone per schedule DEVICES = [d for d in DEVICES if GROUP is None or d["tz"] == GROUP] APPROVED, POSTED = Path("approved"), Path("posted") DRY_RUN = True # flip after a clean week of dry runs MAX_PARALLEL = 3 def next_post(account): folder = APPROVED / account if not folder.exists(): return None posts = sorted(p for p in folder.iterdir() if p.is_dir()) return posts[0] if posts else None def job(device): time.sleep(random.uniform(0, 600)) # spread starts over ten minutes post_dir = next_post(device["account"]) if post_dir is None: return device["account"], "nothing approved" try: result = post(device, post_dir, dry_run=DRY_RUN) if result == "posted": dest = POSTED / device["account"] dest.mkdir(parents=True, exist_ok=True) shutil.move(str(post_dir), str(dest / post_dir.name)) except Exception as exc: # stop this account, keep the rest going result = f"error: {exc!r}" with open("runs.jsonl", "a", encoding="utf-8") as log: log.write(json.dumps({"ts": datetime.now().isoformat(), "account": device["account"], "post": post_dir.name, "result": result}) + "\n") return device["account"], result with ThreadPoolExecutor(max_workers=MAX_PARALLEL) as pool: for account, result in pool.map(job, DEVICES): print(f"{account:<20} {result}") Schedule one run per time zone group. With cron on a server set to UTC, during US daylight time:
# 23:30 UTC = 19:30 in New York (EDT) 30 23 * * * cd /opt/lemon8 && TZ_GROUP=America/New_York python3 run_all.py # 11:30 UTC = 19:30 in Singapore 30 11 * * * cd /opt/lemon8 && TZ_GROUP=Asia/Singapore python3 run_all.py On Windows, Task Scheduler does the same job with one task per group.
The pacing rules I'd start with:
| Rule | Starting value | Why |
|---|---|---|
| Posts per account per day | 1, at most 2 | About what a team posts by hand |
| Start spread | 0 to 10 minutes, random | Ten accounts don't publish in the same second |
| Parallel sessions | 3 | Leaves headroom and keeps the controller light |
| Posting window | Evening in each account's audience time zone | When that audience is scrolling |
| Same photo on two accounts | Never | Reads as duplicate content |
Raise volume only when the content team can actually produce more original posts. The script makes posting cheap. It doesn't make good posts cheap.
Logs and failure rules
Every run writes a line to runs.jsonl and a screenshot before the final tap (plus one after it, when live). During the dry-run week, open the before-post screenshot for every account and check the headline, the photo order and the hashtags.
The failure rules are short:
- A failure stops that account for the day. No retry loops.
- Any unexpected screen ends the run for that phone: a login prompt, a verification, a policy notice, an unusual-activity warning. The script fails on the missing element, and a person opens that phone in the client to deal with it. Never script around a verification step.
- After a Lemon8 update, switch
DRY_RUNback on, run one phone, re-check the locators, then go live again. - If two accounts fail on the same day with the same error, assume the app changed before you assume anything else.
The browser side of each account
Each Lemon8 account has a TikTok account behind it, and usually a brand email and a couple of web logins someone checks for messages and analytics. Those web sessions belong in BitBrowser profiles: one profile per account, using the same proxy region as that account's phone. The phone and the browser for brand-a-us-east both sit in New York, and nothing for that account ever opens from Singapore. Since the same client manages the browser profiles and the cloud phones, phone03 and profile 03 sit next to each other, and mixing two accounts by accident gets a lot harder.

