How to Solve CAPTCHAs in Appium Tests (Python Client)

An Appium CAPTCHA step is the point where a mobile run usually stops and waits for a person. It does not have to. Appium can already screenshot the element that holds the challenge, and CapSkip runs on your own machine and answers it, so the test types the result and carries on. The awkward part is not the solve, it is the session: Appium ends a session that goes quiet, and a solve is exactly the kind of quiet it dislikes. This guide covers both shapes you will hit, a native image field and a CAPTCHA inside a WebView, with Python.
What you need
- CapSkip running on a Windows machine, with the API server switched on.
- Appium 2 and a working driver, UiAutomator2 for Android or XCUITest for iOS, plus a device or emulator you can already drive.
- Python 3.10 or newer with the Appium client and the CapSkip package installed.
- An address for the solver. Local mode answers on 127.0.0.1, so only code running on the CapSkip machine itself can reach it, and Server mode listens on your network address or public IP so a build agent or a CI runner can reach it too. Step 4 covers which one applies, and both live under connection settings.
# pip install Appium-Python-Client capskip pip install Appium-Python-Client capskip
Step 1: give the session room to wait
Do this before anything else, because it is the failure that wastes the most time. Appium keeps a per-session idle timer called newCommandTimeout. It defaults to 60 seconds, and when no new command arrives inside that window the server decides the client has gone away and ends the session. Every later call then fails against a session that no longer exists.
A solve is a gap in the command stream. Your Python code is talking to CapSkip, not to Appium, so for the whole of that solve the driver sits idle. Compare the two clocks and the problem is obvious.
| Clock | Default | What it covers |
|---|---|---|
| Appium newCommandTimeout | 60 seconds | Idle time between two driver commands, per session |
| CapSkip defaultTimeout | 120 seconds | Image CAPTCHA and ALTCHA polling |
| CapSkip recaptchaTimeout | 300 seconds | reCAPTCHA, Turnstile and GeeTest polling |
An image CAPTCHA usually comes back fast enough that nobody notices. A reCAPTCHA on a busy solver does not, and the client is willing to wait five times longer than Appium is. Raise the idle timer past the longest solve you are prepared to wait for.
# pip install Appium-Python-Client capskip
from appium import webdriver
from appium.options.android import UiAutomator2Options
options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app = "/path/to/app.apk"
# Default is 60 seconds. A reCAPTCHA solve can outlast that.
options.new_command_timeout = 300
driver = webdriver.Remote("http://127.0.0.1:4723", options=options)That property writes the appium:newCommandTimeout capability, so a driver or a client that does not expose it under a friendly name takes the same value through set_capability. On iOS the class is XCUITestOptions and the capability is identical, because every driver inherits this one from Appium’s shared base driver rather than implementing its own. Note the server address as well: Appium 2 serves on the bare port, with no path after it.
Step 2: solve a native image CAPTCHA
This is the common shape in a mobile app: an ImageView holding distorted text, and a text field under it. Appium will screenshot a single element and hand it back as base64, which happens to be exactly one of the three input forms the image method takes, so nothing has to touch the disk.
from appium.webdriver.common.appiumby import AppiumBy
from capskip import CapSkip
solver = CapSkip(host="127.0.0.1", port=8080)
# Appium crops the element out of a device screenshot for you.
image = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_image")
result = solver.normal("data:image/png;base64," + image.screenshot_as_base64)
field = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_input")
field.send_keys(result["code"])The code key holds the text that was read. The image method also accepts a file path or a remote URL, so if a step already saved a screenshot you can pass the path instead, but the base64 route avoids temporary files in a test run and is easier to clean up after.
Two things about this method are worth knowing before you build around it. It has no proxy support, which is fine here because the image never leaves your machine. And it polls against the default timeout of 120 seconds rather than the longer reCAPTCHA one, because there is no browser session involved.
Target the image element, not the screen. A full-screen screenshot with the CAPTCHA somewhere in it gives the solver a phone interface to read, and the answer will be wrong in a way that looks like a bad solve rather than a bad crop. If the element you can find is a container with padding and a label in it, find the inner view instead, or the extra pixels will cost you accuracy.
Step 3: solve a reCAPTCHA inside a WebView
The other shape is a login or signup screen that is really a web page in a WebView. There is no image to read here, so switch into the web context and work with the DOM exactly as you would in a browser.
# contexts looks like ['NATIVE_APP', 'WEBVIEW_com.example.app']
web = [c for c in driver.contexts if c.startswith("WEBVIEW")][0]
driver.switch_to.context(web)
# Narrow to g-recaptcha: hCaptcha also carries data-sitekey.
sitekey = driver.find_element(
AppiumBy.CSS_SELECTOR,
".g-recaptcha[data-sitekey]").get_attribute("data-sitekey")
result = solver.recaptcha(sitekey=sitekey, url=driver.current_url)
driver.execute_script(
"document.getElementById('g-recaptcha-response').value = arguments[0];",
result["code"],
)
driver.switch_to.context("NATIVE_APP")Check what the widget actually is before calling the reCAPTCHA method. hCaptcha puts a data-sitekey on its widget too, and it is not a supported type, so a bare attribute selector will happily hand you the wrong key. Look for the g-recaptcha class, or rule hCaptcha out by checking for an h-captcha class or a js.hcaptcha.com script. A WebView login is a common place to meet one. FunCaptcha and Arkose are not supported either.
Read the page URL from the driver rather than hard-coding it. A WebView often loads a URL with a session or a return path in the query string, and the solve is bound to the page it was requested for, so a guessed URL produces a token the site declines.
Filling the response field is enough on a form that posts normally. It is not enough on a page that waits for reCAPTCHA to call it back, which is the arrangement where the submit button is wired to the widget’s callback rather than to the form. That case needs the callback invoked as well, and it is a problem of its own rather than a mobile one: the callback solver page covers what to look for. Switch back to the native context before touching native buttons again, or the next find_element will search the DOM and fail.
If the contexts list only ever shows NATIVE_APP, the WebView is not debuggable. On Android that is an app-side setting the developers control, so it is worth checking with them before assuming Appium is at fault.
Step 4: where the solver runs, and which connection mode that needs
This is the part people get backwards on mobile, so it is worth being blunt about. The solver is called by your Python test code. The phone does not call it, the emulator does not call it, and neither does the Appium server. So the only question is where your test process runs, and the device’s own networking has nothing to do with it.
That means the usual Android emulator advice about reaching the host machine is irrelevant here, and so is the address of a remote Appium server. What matters is simpler: if the process running your test is on the CapSkip machine, loopback is correct. If it is anywhere else, it is not, and the first solve raises a NetworkException.
| Where the test process runs | Which connection mode |
|---|---|
| Your laptop, with CapSkip open on it | Local mode. 127.0.0.1 is genuinely correct |
| Your laptop, driving a remote Appium server or a device cloud | Still Local mode. Only the driver call goes out |
| A build agent on the same network | Server mode, on the CapSkip machine’s private address |
| A hosted CI runner or a container | Server mode with a static public IP and a firewall rule |
Switch CapSkip to Server mode and it listens on your network address or public IP instead of loopback, so any of those reach it over the same HTTP API. A static public IP is recommended when the route crosses the internet, with a firewall rule that allows only the addresses you expect. Server mode changes where the solver listens and nothing else: it is still your hardware, and it is still unmetered. Read the host and port from the environment so one suite runs in both places. The client does not read CAPSKIP_HOST or CAPSKIP_PORT by itself, so pass them to the constructor, as the full example below does.
Full working example
import os
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
from capskip import CapSkip, ApiException, NetworkException, TimeoutException
solver = CapSkip(
host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.environ.get("CAPSKIP_PORT", 8080)),
)
options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app = "/path/to/app.apk"
options.new_command_timeout = 300 # must outlast the longest solve
driver = webdriver.Remote("http://127.0.0.1:4723", options=options)
try:
image = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_image")
result = solver.normal("data:image/png;base64," + image.screenshot_as_base64)
driver.find_element(
AppiumBy.ID, "com.example.app:id/captcha_input").send_keys(result["code"])
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "Submit").click()
except ApiException:
print("the solver refused the image")
except NetworkException:
print("solver unreachable: check the host and the connection mode")
except TimeoutException:
print("no answer inside defaultTimeout")
finally:
driver.quit()All four exceptions derive from CapSkipError, so catching that one instead handles every failure the SDK can raise in a single block. Catch the specific ones when the response differs, as above, and CapSkipError when it does not. Keep driver.quit in a finally block: a test that dies mid-solve otherwise leaves a session holding the device until the idle timer you just raised finally expires.
The rest of the types work the same way from the same client. Turnstile takes a sitekey and a page URL, GeeTest takes a gt value, a challenge and the page URL, and ALTCHA takes the page URL and a challenge endpoint. Every method the package exposes is listed on the Python CAPTCHA solver page, and the image type has a page of its own.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| The session is gone after a slow solve, and every later command fails | newCommandTimeout expired while your code was waiting on the solver | Raise it past the longest solve, not just past the average one |
| A NetworkException on the first solve | CapSkip is not running, or the test process is on a build agent and pointed at loopback | Start CapSkip, then decide between Local mode and Server mode |
| A TimeoutException naming 120 seconds on an image | The image type uses the default polling timeout, not the longer reCAPTCHA one | Check the solver is running and not saturated before raising anything |
| The answer is wrong every time, on a clear image | A full-screen screenshot, or an element that includes padding and a label | Screenshot the innermost view that holds only the CAPTCHA |
| A ValidationException mentioning base64 or a missing file | The element screenshot came back empty, so the string was too short to be read as an image | Check the element was on screen and visible before the screenshot, and that the find actually matched it |
| The reCAPTCHA method returns a token the site rejects every time | The widget is hCaptcha, which carries data-sitekey too and is not a supported type | Narrow the selector to the g-recaptcha class and confirm which widget the page loads |
| The contexts list only contains NATIVE_APP | The WebView is not debuggable, so Appium cannot attach to it | Ask the app team to enable WebView debugging in the build you are testing |
| find_element fails right after a WebView step | The driver is still in the web context and is searching the DOM | Switch back to NATIVE_APP before touching native elements |
| The reCAPTCHA field is filled but the button does nothing | The page is waiting for the widget’s callback rather than reading the field | Invoke the callback as well, or submit the form directly |
| A token the solver returned is rejected by the site | The page URL passed to the solver was guessed rather than read from the WebView | Pass driver.current_url from inside the web context |
FAQ
Does the phone or emulator need to reach the solver?
No, and this is the single most useful thing to understand about the setup. The HTTP call to CapSkip is made by your Python process, so the device only ever sees a screenshot request and a send_keys. Nothing has to be installed on the phone, no traffic from the app is redirected, and the emulator’s own host address never comes into it. A real device on a cable, an emulator and a cloud device all behave identically from the solver’s point of view.
Can Appium tests running in CI use CapSkip?
Yes, over Server mode. A hosted runner cannot see your loopback address, so switch CapSkip to listen on your network address or public IP under connection settings and point the host environment variable at it. Use a static public IP when the route crosses the internet and restrict it with a firewall rule. The solver stays on hardware you own in every case, so nothing about the licence or the number of solves changes when the tests move off your desk.
Is any of this Android only?
No. Swap UiAutomator2Options for XCUITestOptions, imported from appium.options.ios instead, and the shape of the run is identical, because element screenshots, context switching and the idle timer all live above the driver. Only the locators change, since iOS has no resource ids: use an accessibility id where the app sets one, and a predicate or class chain where it does not. The solver never learns which platform it was handed an image from.
Should I solve the CAPTCHA or turn it off for tests?
Turn it off, if it is your app and you can. A test build that skips the check, or a provider test key that always passes, is faster and more deterministic than any solve and removes a dependency from your suite. Solving earns its place when you do not control the screen: a third-party login inside your flow, a partner’s signup form, a staging environment nobody will change for you, or a device-cloud run against production. Those are the cases where the choice is a solver or a person.
The short version
Raise newCommandTimeout before you write anything else, because the default 60 seconds is shorter than the solver is allowed to take and the session dying mid-test looks like a completely different bug. Screenshot the element rather than the screen, pass it as a base64 data URI, and type the result back with send_keys. For a WebView, switch context, read the sitekey and the current URL out of the DOM, fill the response field, then switch back. Move to Server mode the moment the test process stops sharing a machine with the solver.
- The type behind a native app CAPTCHA, and how it is read: the image CAPTCHA solver page.
- Everything the WebView case has in common with the browser case: the reCAPTCHA solver page.
One last thing that shapes how you write the retry. Because this captcha solver runs on hardware you already own, a second attempt on a badly cropped image costs nothing, so a test can afford to take a cleaner screenshot and try again rather than failing the run.
