SurSolver

BlogLemin Captcha Solver API: CROPPED ID, Answer Pair and Form Submission

Lemin Captcha Solver API: CROPPED ID, Answer Pair and Form Submission

· Updated · 6 min read

Lemin Cropped Captcha does not produce a single token. It produces a pair: an answer and the challenge_id it belongs to, and a server accepts them only when both come from the same challenge. That detail causes most failed integrations, followed by the widget ID, which is neither a site key nor a script URL. This guide shows where the CROPPED_ ID lives, how the widget behaves in its two modes, what the task returns, and how to fill the two form fields without mixing pairs.

How Lemin Cropped Captcha works

A page that uses Lemin renders a widget inside a <form> and loads its script from api.leminnow.com. The script URL contains the widget's captcha ID:

snippet.txt
https://api.leminnow.com/captcha/v1/cropped/CROPPED_3dfdd5c_d1872b526b794d83ba3b365db15a200b/js

The widget then runs in one of two modes, chosen by the site's configuration and the visitor.

Mode What the visitor sees What completes it
Background (bypass puzzle) Usually nothing The widget resolves on its own when the page loads
Interactive slider A scene with a puzzle piece to drag into the matching gap A drag that places the piece on the cut-out, then the widget's own verification

When the widget verifies a visitor, it writes two values into hidden inputs inside the form: lemin_answer and lemin_challenge_id. The site posts them with the form, and its server asks Lemin to confirm that this answer belongs to this challenge.

LeminTokenProxyLess and LeminToken cover both modes. The slider has to finish verification before the API returns a result, because a picture of the puzzle is not a completed answer.

Find the CROPPED widget ID

The widget ID is the value you send as websiteKey. It starts with CROPPED_ and contains letters, digits, underscores and hyphens.

  1. Open DevTools and reload the page with the Network panel open.
  2. Filter for leminnow or cropped.
  3. Find the request whose path is /captcha/v1/cropped/<id>/js. The segment between /cropped/ and /js is the ID.

You can also search the page source for CROPPED_. Send the identifier on its own. Pasting the whole script URL answers websiteKey must be the Lemin captcha ID starting with CROPPED_. before anything is charged.

This ID configures the widget and stays the same between page loads. It is different from the challenge_id that the solution returns, which exists for one challenge only.

Create the task

LeminTokenProxyLess solves without a caller proxy. LeminToken takes your proxy in proxy.

snippet.json
{
  "clientKey": "sur_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "task": {
    "type": "LeminTokenProxyLess",
    "websiteURL": "https://example.com/contact",
    "websiteKey": "CROPPED_3dfdd5c_d1872b526b794d83ba3b365db15a200b",
    "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36"
  }
}
  • websiteURL is the full address of the page that shows the widget, including its path.
  • userAgent must be a supported identity; otherwise ERROR_WRONG_USERAGENT lists supportedUserAgents.
  • proxy is required only for LeminToken.

Poll getTaskResult every 3 seconds. A background widget resolves quickly, while a slider takes longer because the puzzle has to be read and the piece placed.

Read the answer pair

snippet.json
{
  "errorId": 0,
  "status": "ready",
  "solution": {
    "answer": "the verification answer",
    "challenge_id": "the matching challenge ID"
  }
}

Both values are separate strings in the public response, with nothing to decode. They are a pair. Keep them together, treat them as opaque and do not trim or reformat either.

Submit lemin_answer and lemin_challenge_id

Post both values together with the form's other fields:

lemin-python.py
# lemin-python.py - solve Lemin Cropped Captcha and post the token with the form.
# Install: pip install requests
# Run: python lemin-python.py

import time
from urllib.parse import urlsplit

import requests


# Your SurSolver API key.
CLIENT_KEY = "sur_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# The page that renders the widget, with its path and query string.
TARGET_URL = "https://example.com/page"
# The CROPPED_ widget ID between /cropped/ and /js in the loader URL.
SITE_KEY = "YOUR_SITE_KEY"
# The User-Agent the solve runs with; send the result with this same user agent.
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36"
# Current application session as a Cookie header value; used on form submission.
SESSION_COOKIES = None
# Where the page posts the form (the form action, or the request in DevTools, Network).
FORM_URL = "https://example.com/submit"
# The form's other fields as a JSON object, such as {"username": "demo"}.
FORM_FIELDS = None
# Text that appears only in a response the site accepted.
SUCCESS_TEXT = "PASTE_ACCEPTED_MARKER"


API = "https://api.sursolver.com"
POLL_ATTEMPTS = 60


def call(path, body):
    # API errors are HTTP 200 with errorId 1; anything else is a transport failure.
    response = requests.post(f"{API}{path}", json=body, timeout=30)
    response.raise_for_status()
    data = response.json()
    if data["errorId"] != 0:
        raise RuntimeError(f'{data["errorCode"]}: {data["errorDescription"]}')
    return data


def solve(task):
    created = call("/createTask", {"clientKey": CLIENT_KEY, "task": task})
    for _ in range(POLL_ATTEMPTS):
        result = call("/getTaskResult", {"clientKey": CLIENT_KEY, "taskId": created["taskId"]})
        if result["status"] == "ready":
            return result["solution"]
        time.sleep(3)
    raise TimeoutError(f'task {created["taskId"]} was not ready in {POLL_ATTEMPTS} polls')


def main():
    session = requests.Session()
    if SESSION_COOKIES:
        for part in SESSION_COOKIES.split(";"):
            if "=" in part:
                name, value = part.strip().split("=", 1)
                session.cookies.set(name, value, domain=urlsplit(FORM_URL).hostname, path="/")
    solution = solve({
        "type": "LeminTokenProxyLess",
        "websiteURL": TARGET_URL,
        "websiteKey": SITE_KEY,
        "userAgent": USER_AGENT,
    })
    response_groups = [{"lemin_answer":"answer","lemin_challenge_id":"challenge_id"}]
    response_fields = next((group for group in response_groups
        if all(isinstance(solution.get(key), str) and solution[key]
               for key in group.values())), None)
    if response_fields is None:
        raise SystemExit("The solution has no complete credential field set.")
    form_fields = {field: solution[key] for field, key in response_fields.items()}

    # The solve ran with USER_AGENT; the submit presents the same one.
    headers = {"User-Agent": USER_AGENT}
    # Send the token the way the page does; some sites name it differently or send JSON.
    response = session.post(
        FORM_URL,
        data={**(FORM_FIELDS or {}), **form_fields},
        headers=headers,
        timeout=30,
    )
    session.close()
    if not response.ok or SUCCESS_TEXT not in response.text:
        raise SystemExit(f"The site did not accept the result (HTTP {response.status_code}).")
    print("The site accepted the result.")


if __name__ == "__main__":
    main()

The target form decides the final field names. If the site wraps the form in a different payload, post one normal submission from a browser, read its request body in the Network panel, and use the same names. In a browser-automation run, set the two hidden inputs with page.evaluate() before clicking the real submit button, and keep the same session and User-Agent as the task.

The widget also requires a <form> ancestor: it walks up from its container to the nearest form and throws if none exists. A page that renders the widget outside a form submits the pair some other way, so check how the page's own script posts it.

Never mix pairs

A Lemin answer is valid only for the challenge that issued it.

  • Do not combine an answer from one task with a challenge_id from another.
  • Do not reuse a pair after the page reloaded and rendered a new widget challenge. Create a new task.
  • Submit promptly after the task is ready.

A server that receives a mismatched pair rejects the form even when both strings look well-formed, and the response rarely says why. When in doubt, log both values together with the task ID so you can see that they came from one result.

Troubleshooting

Symptom Cause Fix
ERROR_INVALID_REQUEST: websiteKey must be the Lemin captcha ID starting with CROPPED_. A script URL, a typo, or another vendor's key Copy the ID between /cropped/ and /js
ERROR_INVALID_REQUEST: proxy is required for this task type. LeminToken was sent without a proxy Add proxy, or use LeminTokenProxyLess
ERROR_WRONG_USERAGENT The User-Agent is not a supported identity Copy a string from supportedUserAgents
The form is rejected A mixed pair, a stale challenge, or different field names Check both values come from one result and match a real submission
ERROR_FAILED The widget did not complete or the ID does not belong to that page Confirm the ID and websiteURL; the charge is refunded once
Rejected on a clean first try The site checks the solving IP Use LeminToken with a sticky proxy and submit through it

A failed or timed-out task returns its original charge once.

Checklist

  1. Copy the CROPPED_ ID from the widget script URL, not the whole URL.
  2. Send the exact page URL as websiteURL and a supported userAgent.
  3. Poll every 3 seconds and do not duplicate a processing task.
  4. Post answer as lemin_answer and challenge_id as lemin_challenge_id, from one result.
  5. Create a new task whenever the widget shows a new challenge.

The Lemin solver page lists the fields at a glance, and the API reference has the schemas. How to identify any captcha and extract its parameters covers the same discovery steps for other widgets.

Current input and replay contract

websiteKey is the widget's CROPPED_ ID, not its script URL and not a per-challenge identifier. The result contains separate answer and challenge_id strings. Submit them together as lemin_answer and lemin_challenge_id; never combine values from different tasks.

The shared script refuses a partial pair before posting. Keep the form's current CSRF/session context and User-Agent, and adapt the final payload when the application does not use ordinary form fields. A widget refresh requires a fresh matching pair. See the request, ready response and paired submission.

More articles