API Integration

Migrating Off a Per-Solve Solver Without a Rewrite

Switching CAPTCHA providers gets scoped like a rewrite. Someone estimates two sprints: new client library, new error taxonomy, new retry logic, another round of load testing. Then they open both API references side by side and find the same two endpoints, the same parameter names, and the same submit-then-poll dance they already implemented eighteen months ago.

Key takeaway: if your current provider speaks the legacy in.php / res.php protocol, the code change is a base URL and an API key. The work that genuinely needs thought sits elsewhere — the error strings your client branches on, the response-format flag your parser assumes, and the shift from budgeting per solve to budgeting concurrency. Plan the migration around those three, not around a client rewrite.

The protocol is older than any one vendor

Several solving services implement the same legacy request shape, popularised by 2Captcha and carried forward because integration code outlives the contracts that paid for it. In that shape you POST a task to in.php and receive a task ID, then GET res.php with action=get&id=<taskId> roughly every five seconds until it stops answering CAPCHA_NOT_READY. CaptchaAI serves that protocol at ocr.captchaai.com, and additionally ships a compatibility emulator that mimics other services by host swap.

The practical consequence is that your submit function, your poll loop, your token-injection code and your parameter names all survive the move untouched:

curl -s https://ocr.captchaai.com/in.php \
  -d key=YOUR_API_KEY \
  -d method=userrecaptcha \
  -d googlekey=SITE_KEY_FROM_THE_PAGE \
  -d pageurl=https://staging.example.com/contact \
  -d json=1
# {"status":1,"request":"2122988149"}

# Poll — roughly every 5s until it stops saying not-ready
curl -s "https://ocr.captchaai.com/res.php?key=YOUR_API_KEY&action=get&id=2122988149&json=1"
# {"status":0,"request":"CAPCHA_NOT_READY"}

So the honest scope of the migration is: change the host, change the key, then go and verify the four things the protocol never standardised.

What a migration actually touches

1. The host and the key

If either is a constant compiled into your client, that is the first commit — not because the migration demands it, but because a rollback does. Move both into configuration, ship that change against your current provider, and confirm nothing moved. You now have a tested switch.

# before
BASE = "https://api.some-solver.example/in.php"
KEY  = "hardcoded-32-char-key"

# after
BASE = os.environ["SOLVER_BASE_URL"]   # https://ocr.captchaai.com
KEY  = os.environ["SOLVER_API_KEY"]    # 32 characters

2. The response-format flag

json=1 gives you structured responses. Omit it and you get the legacy plain-text format the protocol was born with. Which one your existing integration uses is often nobody's documented decision — it is whatever the original author copied. Grep the parser before you assume.

# json=1
{"status":1,"request":"2122988149"}
{"status":0,"request":"CAPCHA_NOT_READY"}
{"status":1,"request":"03AGdBq26...token..."}

# legacy plain text
OK|2122988149
CAPCHA_NOT_READY
OK|03AGdBq26...token...

The rule for cutover week: keep the format you already parse. Both are available, so there is no reason to change host, key and parser in one commit. Move to json=1 afterwards as its own revertible change, when a stray OK| glued to a token is the only thing that can have gone wrong.

3. Error-code semantics — the part that actually bites

The strings look shared. The set is not. Your old client branches on a vocabulary it learned from one provider's docs, and after the swap it will meet strings it has no branch for. CaptchaAI's documented codes include CAPCHA_NOT_READY, ERROR_UNSOLVABLE and ERROR_ZERO_BALANCE.

The failure mode is quiet, which is why it deserves the planning your client library does not. An unknown terminal error handled as transient becomes an infinite retry loop; an unknown transient error handled as fatal silently drops work. Neither throws.

Classify by policy rather than by exhaustive enumeration, and give unknowns a bucket of their own:

NOT_READY        = "CAPCHA_NOT_READY"       # legacy spelling, missing T — match exactly
FATAL_TO_ACCOUNT = {"ERROR_ZERO_BALANCE"}   # account-wide; page a human, never retry
FATAL_TO_TASK    = {"ERROR_UNSOLVABLE"}     # this task is finished; resubmit at most once

def classify(request_value):
    if request_value == NOT_READY:
        return "wait"
    if request_value in FATAL_TO_ACCOUNT:
        return "halt"
    if request_value in FATAL_TO_TASK:
        return "drop"
    log.warning("unmapped solver response: %r", request_value)
    return "unknown"        # treat as drop, alert once, then map it deliberately

That unknown branch is what makes a migration observable: the first week of logs tells you the new endpoint's real error vocabulary, and every string that appears there gets mapped on purpose instead of by guess. Keep the misspelling intact while you are at it — CAPCHA_NOT_READY is a legacy artifact every implementation preserves, and "fixing" it in your constants file breaks the match.

One semantic worth calling out: a balance or account-state error is account-wide, not task-level. It will not clear by retrying, and it should page someone rather than feed a retry storm.

4. Per-task proxy parameters

If your old integration solved through your own egress, carry the proxy and proxytype parameters across — they exist here too. If it did not, resist adding them now. Every extra variable you change is one you have to eliminate later when the numbers look odd.

The pricing-model rethink is the real migration

This is the part that is genuinely not a host swap.

Per-solve pricing meters output: you pay for units of work, so every solve is a line item. Thread-based pricing meters occupancy: you buy concurrent slots, a slot is held from submission until you have the answer, and it then frees for the next task. CaptchaAI prices threads — no per-CAPTCHA fee, no daily cap and no surcharge by challenge type — with published tiers of BASIC at $15/month for 5 threads, ADVANCE at $90/month for 50 threads (from around $0.09 per 1,000) and ENTERPRISE at $300/month for 200 threads (from around $0.030 per 1,000), plus STANDARD, PREMIUM, CORPORATE and VIP rungs filling in the ladder.

Per-solve pricing bakes habits into a codebase. Migration is the moment to audit them:

  • "Only solve if we really have to" gates. Still useful for latency and for staying polite to the target site. No longer a cost saving.
  • Answer caching to avoid double-charging. Keep dedup that protects correctness, drop the cost rationale — challenge tokens are single-use and expire in minutes, so caching them bought little.
  • Stingy retry budgets. A retry no longer costs money; it costs slot-time. A task that ends in ERROR_UNSOLVABLE still held a thread for its full in-flight duration and returned nothing, so retries belong in your concurrency arithmetic rather than your invoice forecast.
  • Batching work into "cheap" windows. Replaced by the opposite instinct: flatten peaks, because peak concurrency is now the only number that sets your bill.

And the forecast itself changes shape. Monthly solve volume no longer predicts spend; peak concurrency does, and it is roughly peak arrival rate multiplied by how long each task stays in flight. Since published solve times run from under half a second for image CAPTCHAs to under 60 seconds for reCAPTCHA v2, the same volume can land two tiers apart depending on challenge mix — the arithmetic, the worked examples and a way to measure your real p99 concurrency are in sizing threads.

One operational consequence gets missed. Under per-solve pricing, a runaway retry loop announces itself on the bill. Under thread pricing it announces itself as latency instead, because the retries are quietly eating slots other work needed. So migrate the alerting too: replace the "daily spend" alarm you are deleting with one on semaphore wait time or queue depth.

Cutting over without a rewrite

Four steps, each independently revertible.

Step 1 — ship the config change alone. Host and key from environment, still pointing at the old provider. No behaviour change, and now the switch exists.

Step 2 — shadow a slice. Send a small fraction of live tasks to both endpoints, use only the answer you already trusted, and log the comparison. You are checking three things: outcome class, elapsed time, and any string that lands in the unknown bucket.

async def solve_with_shadow(payload, frac=0.05):
    primary = asyncio.create_task(run(OLD_BASE, payload))
    if random.random() < frac:
        asyncio.create_task(compare(NEW_BASE, payload))  # logged, never used
    return await primary

async def compare(base, payload):
    started = time.monotonic()
    outcome = await run(base, payload)                   # returns a classify() label
    log.info("shadow base=%s outcome=%s elapsed=%.1f",
             base, outcome, time.monotonic() - started)

Two cautions. Shadow traffic consumes slots on both sides, so size the fraction against your thread count rather than picking a round number. And if your flow submits the token, shadowing doubles requests to the target site — keep the shadow read-only or run it against your own staging environment, rather than doubling load on someone else's server to test a vendor swap.

Step 3 — canary. Route a percentage of real traffic to the new host and watch the same three signals for a full traffic cycle, including your nightly peak. A migration that looks perfect at 2pm can still be sized wrong at 3am.

Step 4 — flip, and keep the old config for one revert cycle. Rollback should be an environment variable, not a deploy.

Verify before you flip

  • Parameter names for every method you actually use, one by one.
  • Whether you send json=1, matched to what your parser expects.
  • Error constants mapped, with the unknown bucket logging literal strings.
  • Per-type deadlines checked against the published latency envelopes for the types you submit.
  • A semaphore in the client sized to your thread count.
  • Webhook endpoints re-registered, if you use webhook delivery rather than polling.
  • proxy / proxytype carried across only if you were already using them.
  • Alerting moved from spend to concurrency.
  • Rollback exercised once, deliberately, before you need it.

The targets do not change either

A provider migration changes nothing about what you should be automating. The same rules apply after the swap as before: honour robots.txt, stay inside published rate limits, read the terms of service for the site you are testing, and keep the work legitimate — your own QA and staging suites, accessibility testing, uptime monitoring, and lawful data collection. Shadow and canary phases are exactly when it is tempting to double request volume against a third-party site; use staging.

FAQ

Do I need a new client library to migrate? Usually not. If your current provider speaks the legacy in.php / res.php protocol, the submit call, the poll loop and the token injection are already correct — what changes is the base URL and the key. Budget your effort for the error map and the concurrency model instead.

Will my existing error handling still work? Partly. CAPCHA_NOT_READY behaves the same, and codes like ERROR_UNSOLVABLE and ERROR_ZERO_BALANCE are documented on both sides of a legacy-protocol move. The risk is the strings your client has never seen. Add an unknown branch that logs the literal value and treats it as terminal, then map what actually appears.

How do I turn my per-solve budget into a thread count? You cannot convert it directly — they measure different things. Monthly volume tells you nothing about how many tasks are unresolved at once, which is what a thread buys. Measure peak arrival rate and average in-flight time, multiply, add headroom, and size to that.

Can I run both providers at once during cutover? Yes, and it is the safest way to do it. Just remember that shadow tasks occupy concurrency on both accounts, and that duplicated submissions must not become duplicated load on the target site. Log and discard the shadow result until you are ready to promote it.

Flip the base URL, then think about threads

These migrations look expensive because "change providers" sounds like a rewrite, when the shared legacy protocol makes it a configuration change. Do the small things carefully — verify parameters per method, keep your response format stable through cutover, map error strings instead of assuming them — and spend the saved effort on the one thing that really is different: sizing concurrent slots instead of forecasting solves.

When you are ready to diff the parameter list against what you already run, the endpoints, documented error codes, thread tiers and examples in cURL, Python, Node.js, PHP, Go, Java, .NET, Ruby, Rust and Postman are at getcaptchaai.com.

Comments are disabled for this article.