Setup
Before you start
- Python 3.9 or newer and Scrapy 2.11 or newer
requestsfor the API helper andform2requestto resubmit the page’s form- A bypass.so API key (create one) with a positive balance
Install
python -m pip install scrapy requests form2request
scrapy startproject myprojectStore your key
The examples read your key from the BYPASS_API_KEY environment variable, so it never lands in source control.
export BYPASS_API_KEY="your-api-key"Check that the key works: curl -H "Authorization: Bearer $BYPASS_API_KEY" https://bypass.so/balance returns your balance.
Overview
How it fits together
A downloader middleware sees every response before your spider does. When a response is a captcha page, it solves the captcha with bypass.so and sends the answer back to the site. Your spider code does not change.
- 01
Spot the captcha
Each response is checked for a math question, an image captcha or a widget sitekey.
find_captcha - 02
Solve off the event loop
The blocking helper runs in a worker thread while the crawl carries on.
deferToThread - 03
Post the answer
The answer goes into the page’s own form, with the original callback and meta.
form2request - 04
Carry on crawling
The site lets the request through and your callback gets the real page.
parse
Project layout used below:
myproject/
scrapy.cfg
myproject/
bypass_client.py API helper (step 1)
middlewares.py captcha middleware (step 2)
settings.py enable it (step 3)
spiders/
products.py your spider (step 4)Step 1
Add the API helper
This is the same helper as in the Python guide: one solve function that creates a task, polls for the result, raises a BypassError with the API code and enforces a timeout. Save it as myproject/bypass_client.py.
import os
import time
import requests
API_URL = "https://bypass.so"
CLIENT_KEY = os.environ.get("BYPASS_API_KEY", "")
class BypassError(Exception):
def __init__(self, code, description="", task_id=None):
super().__init__(f"{code}: {description}")
self.code = code
self.description = description
self.task_id = task_id
def _read(response):
try:
data = response.json()
except ValueError:
raise BypassError("HTTP_ERROR", f"HTTP {response.status_code} without a JSON body")
if data.get("errorId"):
raise BypassError(
data.get("errorCode", "ERROR_UNKNOWN"),
data.get("errorDescription", ""),
data.get("taskId"),
)
return data
def solve(task_type, params, timeout=120, poll_every=2.0):
if not CLIENT_KEY:
raise BypassError("ERROR_KEY_INVALID", "set the BYPASS_API_KEY environment variable")
body = {"clientKey": CLIENT_KEY, "type": task_type, **params}
data = _read(requests.post(f"{API_URL}/createTask", json=body, timeout=30))
task_id = data["taskId"]
deadline = time.monotonic() + timeout
while data.get("status") != "ready":
if time.monotonic() > deadline:
raise TimeoutError(f"task {task_id} was not ready after {timeout}s")
time.sleep(poll_every)
data = _read(requests.get(
f"{API_URL}/getTaskResult/{task_id}",
params={"clientKey": CLIENT_KEY},
timeout=30,
))
solution = dict(data.get("solution") or {})
solution["taskId"] = task_id
return solution
def token_of(solution):
return solution.get("gRecaptchaResponse") or solution.get("token") or solution.get("text")
def balance():
headers = {"Authorization": f"Bearer {CLIENT_KEY}"}
return _read(requests.get(f"{API_URL}/balance", headers=headers, timeout=30))
def report_bad(task_id):
headers = {"Authorization": f"Bearer {CLIENT_KEY}"}
return _read(requests.post(
f"{API_URL}/reportBad",
params={"task_id": task_id},
headers=headers,
timeout=30,
))Step 2
Add the captcha middleware
find_captcha decides whether a response is a captcha page and what to send. process_response solves it and returns a new request that posts the answer through the page’s form. Pages without a captcha pass straight through.
import logging
from form2request import form2request
from scrapy.utils.defer import maybe_deferred_to_future
from twisted.internet.threads import deferToThread
from myproject.bypass_client import BypassError, solve, token_of
logger = logging.getLogger(__name__)
# task type, CSS selector of the widget, form field that receives the token
WIDGETS = [
("TurnstileTask", ".cf-turnstile[data-sitekey]", "cf-turnstile-response"),
("RecaptchaV2Task", ".g-recaptcha[data-sitekey]", "g-recaptcha-response"),
]
class BypassCaptchaMiddleware:
"""Detects captcha pages, solves them with bypass.so and resubmits the form."""
def __init__(self, settings):
self.max_attempts = settings.getint("BYPASS_MAX_ATTEMPTS", 2)
self.math_selector = settings.get("BYPASS_MATH_SELECTOR", "#captcha-question::text")
self.image_selector = settings.get("BYPASS_IMAGE_SELECTOR", "img.captcha::attr(src)")
self.answer_field = settings.get("BYPASS_ANSWER_FIELD", "captcha")
@classmethod
def from_crawler(cls, crawler):
return cls(crawler.settings)
def find_captcha(self, response):
"""Return (task_type, params, field, form_xpath) for a captcha page, else None."""
if not hasattr(response, "css"): # images, PDFs and other binary bodies
return None
for task_type, selector, field in WIDGETS:
sitekey = response.css(selector).attrib.get("data-sitekey")
if sitekey:
params = {"websiteURL": response.url, "websiteKey": sitekey}
return task_type, params, field, "//form[.//*[@data-sitekey]]"
answer_form = f'//form[.//*[@name="{self.answer_field}"]]'
question = response.css(self.math_selector).get()
if question:
return "MathCaptchaTask", {"captchaText": question.strip()}, self.answer_field, answer_form
src = response.css(self.image_selector).get()
if src and src.startswith("data:image/"):
image_b64 = src.split(",", 1)[1]
return "ImageToTextTask", {"imageBase64": image_b64}, self.answer_field, answer_form
return None
async def process_response(self, request, response, spider=None):
found = self.find_captcha(response)
if found is None:
return response
attempt = request.meta.get("bypass_attempt", 0) + 1
if attempt > self.max_attempts:
logger.warning("captcha still shown after %d attempts: %s", self.max_attempts, response.url)
return response
task_type, params, field, form_xpath = found
try:
# solve() blocks while it polls, so run it on the reactor thread pool.
# Awaiting the Deferred pauses only this response; the crawl keeps going.
solution = await maybe_deferred_to_future(deferToThread(solve, task_type, params))
except (BypassError, TimeoutError) as err:
logger.error("could not solve %s on %s: %s", task_type, response.url, err)
return response
logger.info("solved %s on %s (task %s)", task_type, response.url, solution["taskId"])
form = response.xpath(form_xpath)
if not form:
logger.error("no form to submit the answer on %s", response.url)
return response
# Fill the answer into the page's own form and send it like a browser would.
return form2request(form, {field: token_of(solution)}).to_scrapy(
callback=request.callback,
errback=request.errback,
cb_kwargs=request.cb_kwargs,
meta={**request.meta, "bypass_attempt": attempt, "bypass_task_id": solution["taskId"]},
priority=request.priority + 1,
dont_filter=True,
)Why a thread, and why it does not block the crawl
deferToThread runs it in the reactor’s thread pool and returns a Deferred. Because process_response is a coroutine, awaiting that Deferred pauses only this one response while every other request keeps downloading. maybe_deferred_to_future makes the same code work with the asyncio reactor and the classic Twisted reactor.What it detects
| Captcha | Detected by | Where the answer goes |
|---|---|---|
CaptchaMathMathCaptchaTask | Detected byText matched by BYPASS_MATH_SELECTOR | Where the answer goesPosted as BYPASS_ANSWER_FIELD in the same form |
CaptchaImageImageToTextTask | Detected byA data:image/… URI matched by BYPASS_IMAGE_SELECTOR | Where the answer goesPosted as BYPASS_ANSWER_FIELD |
CaptchareCAPTCHA v2RecaptchaV2Task | Detected by.g-recaptcha[data-sitekey] in the HTML | Where the answer goesg-recaptcha-response |
CaptchaTurnstileTurnstileTask | Detected by.cf-turnstile[data-sitekey] in the HTML | Where the answer goescf-turnstile-response |
| CaptchareCAPTCHA v3, hCaptchaComing soon | Detected byNot detected yet | Where the answer goesAdd a row to WIDGETS once these types are live |
Step 3
Enable it in settings.py
Order 560 places the middleware after the redirect (600) and cookie (700) middlewares, so it sees the final page with its cookies already stored. Adjust the selectors to match the captcha markup of your site.
# Add to myproject/settings.py
DOWNLOADER_MIDDLEWARES = {
# after RedirectMiddleware (600) and CookiesMiddleware (700), so it sees the final page
"myproject.middlewares.BypassCaptchaMiddleware": 560,
}
BYPASS_MAX_ATTEMPTS = 2 # solves per request before giving up
BYPASS_MATH_SELECTOR = "#captcha-question::text" # text of a math question
BYPASS_IMAGE_SELECTOR = "img.captcha::attr(src)" # data: URI of an image captcha
BYPASS_ANSWER_FIELD = "captcha" # input that takes the text answer
REACTOR_THREADPOOL_MAXSIZE = 20 # solves wait in this pool, alongside DNS lookups
ROBOTSTXT_OBEY = True
CONCURRENT_REQUESTS_PER_DOMAIN = 4
AUTOTHROTTLE_ENABLED = TrueStep 4
Run a spider
An ordinary spider. It never mentions the captcha: when a page comes back as a captcha, the middleware solves it and the callback runs on the page behind it.
import scrapy
class ProductsSpider(scrapy.Spider):
name = "products"
start_urls = ["https://example.com/products"]
def parse(self, response):
for product in response.css(".product"):
yield {
"name": product.css("h2::text").get(),
"price": product.css(".price::text").get(),
}
next_page = response.css("a.next::attr(href)").get()
if next_page:
yield response.follow(next_page, self.parse)export BYPASS_API_KEY="your-api-key"
scrapy crawl products -O products.json[myproject.middlewares] INFO: solved MathCaptchaTask on https://example.com/products (task 9d6e9cf6-…) [scrapy.core.engine] DEBUG: Crawled (200) <GET https://example.com/products> (referer: https://example.com/products) 'item_scraped_count': 2,
Try it against a local page
This small server shows a math captcha until the right answer is posted, then sets a cookie and redirects to a product list. Start it, set start_urls to http://127.0.0.1:8765/ and run the spider. Each run solves one math task with your key.
"""Local test site: / shows a math captcha until the right answer is posted."""
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs
CAPTCHA = b"""<!doctype html><html><body><h1>Are you human?</h1>
<form method="post" action="/"><label>Solve: <span id="captcha-question">3+4</span></label>
<input name="captcha"><input type="hidden" name="next" value="/"><button>Continue</button></form></body></html>"""
PRODUCTS = b"""<!doctype html><html><body>
<div class="product"><h2>Blue mug</h2><span class="price">9.00</span></div>
<div class="product"><h2>Red mug</h2><span class="price">11.00</span></div></body></html>"""
class H(BaseHTTPRequestHandler):
def send(self, code, body=b"", headers=()):
self.send_response(code)
for k, v in headers:
self.send_header(k, v)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/robots.txt":
return self.send(404)
ok = "human=1" in (self.headers.get("Cookie") or "")
print("SERVER GET", self.path, "->", "products" if ok else "captcha page", flush=True)
self.send(200, PRODUCTS if ok else CAPTCHA)
def do_POST(self):
form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode())
answer = form.get("captcha", [""])[0]
print("SERVER POST / captcha=%r" % answer, flush=True)
if answer == "7":
self.send(302, headers=[("Location", "/"), ("Set-Cookie", "human=1; Path=/")])
else:
self.send(200, CAPTCHA)
def log_message(self, *a):
pass
HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()python captcha_site.py 8765Reference
Task type reference
What each type expects and returns. Prices are per 1,000 solved tasks; larger top-ups add bonus credit (see pricing).
| Task type | You send | You get | Price / 1k |
|---|---|---|---|
Task typeMathCaptchaTask | You sendcaptchaText | You gettext | Price / 1k$0.35 |
Task typeImageToTextTask | You sendimageBase64 | You gettext | Price / 1k$0.28 |
Task typeAudioCaptchaTask | You sendaudioBase64 | You gettext | Price / 1k$0.35 |
Task typeRecaptchaV2Task | You sendwebsiteURLwebsiteKey | You getgRecaptchaResponse and token, plus tokenField | Price / 1k$0.56 |
Task typeTurnstileTask | You sendwebsiteURLwebsiteKey | You gettoken, plus tokenField | Price / 1k$0.84 |
Task typeRecaptchaV3TaskComing soon | You sendwebsiteURLwebsiteKeyaction | You getgRecaptchaResponse and token, plus tokenField | Price / 1kAt launch |
Task typeHCaptchaTaskComing soon | You sendwebsiteURLwebsiteKey | You gettoken, plus tokenField | Price / 1kAt launch |
Errors
Troubleshooting
| Error | Source | What it means | How to fix it |
|---|---|---|---|
ErrorERROR_KEY_INVALID | SourceHTTP 403 | What it meansThe key is missing, mistyped or revoked. | How to fix itCheck that BYPASS_API_KEY is set in the same shell that runs your code, with no quotes or spaces around it. |
ErrorERROR_INSUFFICIENT_CREDITS | SourceHTTP 402 | What it meansYour balance cannot cover the task price. | How to fix itTop up from the dashboard. Nothing was charged for this request. |
ErrorERROR_TASK_NOT_SUPPORTED | SourceHTTP 200 | What it meansThe type string is unknown, or the type is still coming soon (reCAPTCHA v3, hCaptcha). | How to fix itCopy the exact name, for example TurnstileTask. Names are case-sensitive. Nothing is charged. |
ErrorERROR_BAD_REQUEST | SourceHTTP 400 | What it meansThe body is not valid JSON, or a field has the wrong type. | How to fix itSend Content-Type: application/json and string values for clientKey, type and the task fields. |
ErrorERROR_CAPTCHA_UNSOLVABLE | SourceHTTP 200 | What it meansThis task could not be completed. | How to fix itYou are not charged. Check the image, sitekey and page URL, then submit a new task. |
ErrorERROR_NOT_FOUND | SourceHTTP 404 | What it meansThe taskId is unknown or belongs to another key. | How to fix itPoll with the taskId returned by your own createTask call, using the same key. |
ErrorERROR_INTERNAL | SourceHTTP 500 | What it meansSomething went wrong on our side. | How to fix itRetry after a short pause. The helper’s retry wrapper does this for you. |
| ErrorCaptcha not detected | SourceHTTP your code | What it meansThe page uses other markup, or the widget is added by JavaScript. | How to fix itPoint BYPASS_MATH_SELECTOR or BYPASS_IMAGE_SELECTOR at your page. Widgets injected by scripts are not in the HTML Scrapy downloads; use a browser for those pages. |
| ErrorCaptcha still shown after N attempts | SourceHTTP your code | What it meansThe site rejected the answer, or it expects extra fields or a different form. | How to fix itCheck which form the answer is posted to, and that cookies are enabled. Report a solved task that was rejected with report_bad. |
| ErrorCrawl slows down while solving | SourceHTTP your code | What it meansSolves wait in the reactor thread pool, which DNS lookups also use. | How to fix itRaise REACTOR_THREADPOOL_MAXSIZE above the number of solves you expect at once. |
| ErrorHelper timeout | Sourceyour code | What it meansNo result inside your timeout. | How to fix itToken types usually need several seconds. Keep at least 60–120 s and retry with a new task. |
ErrorHTTP_ERROR | Sourceyour code | What it meansA proxy or network layer answered without JSON. | How to fix itRetry with backoff. If it persists, check that your code calls https://bypass.so. |
Every error body has the same shape: {"errorId":1,"errorCode":"…","errorDescription":"…"}. Full list in the API reference.
Production
Best practices
Keep the key out of code
Read it from an environment variable or your secret store. Never commit it or ship it to a web page.
Solve right before submit
Tokens are short-lived. Fill the form first, then request the token and submit at once.
Cap your concurrency
Start with 5–10 parallel tasks and raise it while you watch error rates and spend.
Log every taskId
It is the only thing you need to debug a result or report a wrong answer.
Match the page exactly
Send the URL where the widget renders and the sitekey from that page, not from a different form.
Report wrong answers
If a site rejects a solved result, call reportBad with its taskId. The price returns to your balance once.
FAQ
Questions
solve() blocks while it waits for the result, often several seconds. Scrapy runs every request on one event loop, so a blocking call there would freeze the whole crawl. deferToThread moves the wait to a worker thread, and await pauses only the response that hit the captcha.maybe_deferred_to_future adapts the result to whichever one is running. A fully async client would save a thread per solve, which only matters at hundreds of solves in parallel.form2request, keeping the callback, cb_kwargs and meta of the original request. Cookies from the captcha page are sent along by Scrapy’s cookie middleware. If the site redirects back afterwards, your callback receives the real page.Policy
Responsible use
Use it where you are allowed to
Automate pages you own, test environments you run, or sites whose terms permit automated access. Do not use bypass.so for fraud, account takeover, spam, scalping, evading bans or collecting personal data without a lawful basis. Keep request rates reasonable and stop when a site asks you to. Our acceptable use policy applies to every key, and keys used for abuse are closed.
Keep going
Next steps
- API referenceEvery endpoint, field and error code
- PricingPer-task prices and top-up bonuses
- Python guideScripts, backends and data jobs
- Node.js guideServices, CLIs and serverless functions
- Go guideServices and workers on the standard library
- PHP guideWeb apps, cron jobs and CLI scripts
- Java guideJVM backends on Java 17+, no libraries
- C# / .NET guideAsync .NET 8 services and tools
- Ruby guideScripts, Rails apps and background jobs
- Selenium guideQA suites and WebDriver flows
- Playwright guideEnd-to-end tests in Node.js or Python
- Puppeteer guideChrome automation scripts