Captcha token expired: causes and how to fix it
A captcha token is a short-lived proof that a challenge was passed. Scarnox's PopularCaptcha tokens expire about two minutes after they are issued. If you submit a token after that window, the site rejects the form, often with a message saying the captcha has expired or is invalid. The fix is almost always about timing: solve the captcha as close to the submit as you can, use each token once, and retry only when you know the first attempt was not accepted.
Why tokens expire
A token is not a permanent credential. The site wants to know that a person (or a solver acting for one) completed the challenge recently, so it gives the token a short life. The clock starts when the token is issued, not when you first call the API. Anything that happens between the solve and the submit eats into the two minutes.
Common causes
- Solving too early. Pre-solving a batch of tokens and keeping them for later is the most frequent cause. By the time a token is used, it is stale.
- Slow code between solve and submit. A long database write, a queue, or a
time.sleepplaced before the form post can push the token past its window. - Reusing a token. A token that was already accepted, or that a failed attempt already used, will not work again. Treat each token as single-use.
- Reloading the page. If the page is refreshed after the solve, the form may issue a new challenge and invalidate the token you hold.
The fix: solve right before you submit
The simplest reliable pattern is to make the solve and the submit one step. Request a token, put it in the form, post the form, and do nothing slow in between:
import requests
API_KEY = "scarnox_YOURAPIKEY"
CREATE_URL = "https://api.scarnox.com/api/tasks/create"
def fresh_token(sitekey: str, page_url: str) -> str:
r = requests.post(
CREATE_URL,
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"captcha_type": "PopularCaptchaTaskProxyless",
"site_key": sitekey,
"site_url": page_url,
},
timeout=185, # the call blocks until the token is ready
)
data = r.json()
if data.get("errorId") != 0:
raise RuntimeError(f"{data.get('errorCode')}: {data.get('errorDescription')}")
return data["solution"]["token"]
def submit_form(page_url: str, sitekey: str, form: dict) -> requests.Response:
token = fresh_token(sitekey, page_url) # solve right before posting
return requests.post(
page_url,
data={**form, "h-captcha-response": token, "g-recaptcha-response": token},
timeout=30,
)
Notice that submit_form does not keep a token in a cache or a queue. It asks for one, posts it, and moves on. Failed solves are not charged, so a failed request costs you nothing on the solve side.
Retry safely
Some sites reject an expired token with a clear response. Others return a normal page with an error message inside it, so a status code alone will not tell you. Look at the response the site actually returns for an expired token and build your check around that. A retry loop could look like this:
def submit_with_retry(page_url: str, sitekey: str, form: dict, attempts: int = 2):
for _ in range(attempts):
resp = submit_form(page_url, sitekey, form)
if resp.status_code < 400 and "captcha" not in resp.text.lower():
return resp # accepted, as far as this site's response shows
raise RuntimeError("captcha token rejected after retries")
Be careful with retries on forms that create something. If the first attempt was actually accepted but your code misread the response, a retry can create a duplicate account, order or message. Only retry when you are sure the first post was not accepted, and make the check specific to the site you are working with.
Keep the timing honest
- Do not solve in a loop of tokens you intend to use later. Solve per submit.
- Keep the work between the solve and the post to a minimum.
- Use a timeout of about 185 seconds on the solve request, because the call blocks until the token is ready. A separate timer on your own form post should be much shorter than the token's life.
- If you need a faster pipeline, see captcha solving speed, which covers parallel solves without storing tokens.
Common mistakes
- Caching tokens in a dictionary or Redis for later. The cache looks harmless until a token is used after its window and every submit fails.
- Solving once per session. A long-lived session still needs a new token for each form post, because each post may run its own challenge check.
- Sleeping between solve and submit. A rate limiter or a deliberate pause placed after the solve spends the token's life. Put any pacing before the solve instead.
- Treating every failure the same. A timeout on your own side, a rejected token and a site error need different handling. Log them separately.
Log enough to diagnose it
When a submit fails, record the time the token was issued, the time the form was posted, the HTTP status and the first few hundred characters of the response. With those four values you can tell at a glance whether the token aged out or the site rejected it for another reason. Do not log the full token in shared logs, since a valid token is a credential for that one challenge.
What to check when it still fails
- Print the time between the token being returned and the form post.
- Confirm the
site_urlmatches the page that shows the captcha. - Confirm the sitekey was read from the current page, not an old copy.
- Confirm the form field names. PopularCaptcha tokens go in
h-captcha-response, and ofteng-recaptcha-responseas well.
For the full list of error responses and how to handle each one, see captcha solver error handling.