How to Solve CAPTCHAs in Appium Tests (Python Client)

appium captcha - 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.

ClockDefaultWhat it covers
Appium newCommandTimeout60 secondsIdle time between two driver commands, per session
CapSkip defaultTimeout120 secondsImage CAPTCHA and ALTCHA polling
CapSkip recaptchaTimeout300 secondsreCAPTCHA, 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 runsWhich connection mode
Your laptop, with CapSkip open on itLocal mode. 127.0.0.1 is genuinely correct
Your laptop, driving a remote Appium server or a device cloudStill Local mode. Only the driver call goes out
A build agent on the same networkServer mode, on the CapSkip machine’s private address
A hosted CI runner or a containerServer 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 seeCauseFix
The session is gone after a slow solve, and every later command failsnewCommandTimeout expired while your code was waiting on the solverRaise it past the longest solve, not just past the average one
A NetworkException on the first solveCapSkip is not running, or the test process is on a build agent and pointed at loopbackStart CapSkip, then decide between Local mode and Server mode
A TimeoutException naming 120 seconds on an imageThe image type uses the default polling timeout, not the longer reCAPTCHA oneCheck the solver is running and not saturated before raising anything
The answer is wrong every time, on a clear imageA full-screen screenshot, or an element that includes padding and a labelScreenshot the innermost view that holds only the CAPTCHA
A ValidationException mentioning base64 or a missing fileThe element screenshot came back empty, so the string was too short to be read as an imageCheck 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 timeThe widget is hCaptcha, which carries data-sitekey too and is not a supported typeNarrow the selector to the g-recaptcha class and confirm which widget the page loads
The contexts list only contains NATIVE_APPThe WebView is not debuggable, so Appium cannot attach to itAsk the app team to enable WebView debugging in the build you are testing
find_element fails right after a WebView stepThe driver is still in the web context and is searching the DOMSwitch back to NATIVE_APP before touching native elements
The reCAPTCHA field is filled but the button does nothingThe page is waiting for the widget’s callback rather than reading the fieldInvoke the callback as well, or submit the form directly
A token the solver returned is rejected by the siteThe page URL passed to the solver was guessed rather than read from the WebViewPass 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.

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.