The EZCaptchaSolver JavaScript SDK is an open-source JavaScript and TypeScript client maintained by EZXLabs for its CAPTCHA recognition task API. It provides typed requests and a Promise-based client for the supported task types below; the package also includes a TLS forwarding task that does not solve CAPTCHAs. For the wider SDK family, see the EZCaptchaSolver SDK product page; for HTTP request and response fields, see the EZCaptchaSolver API reference; for JavaScript usage, see the examples in this repository.
Zero runtime dependencies. Built on the platform fetch, so it runs on Node, Deno, Bun and edge runtimes alike.
Captcha task types come in a synchronous and an asynchronous form:
- Synchronous: the request blocks after the task is created and returns once the task is done.
- Asynchronous: creating the task returns a task ID, and the result is fetched later by polling that ID. This suits captcha types that take a while to solve.
A captcha type can support both forms at once, and almost every type supports the synchronous one. A few types are asynchronous only.
Every type has two methods: solveX creates the task and polls for the result, syncSolveX runs it through the synchronous endpoint. Both return a promise — sync names the endpoint, not the calling convention.
| Task type | Modes | Example | Description |
|---|---|---|---|
ReCaptchaV2TaskProxyless |
all | Example | reCAPTCHA v2 |
ReCaptchaV2TaskProxylessS9 |
all | Example | reCAPTCHA v2, returns a token scored ≥ 0.9 |
ReCaptchaV2STaskProxyless |
all | Example | reCAPTCHA v2 carrying the challenge-bound s parameter |
ReCaptchaV2EnterpriseTaskProxyless |
all | Example | reCAPTCHA v2 Enterprise |
ReCaptchaV2SEnterpriseTaskProxyless |
all | Example | reCAPTCHA v2 Enterprise with the s parameter |
ReCaptchaV2Classification |
sync | Example | Image classification for a reCAPTCHA v2 grid |
| Task type | Modes | Example | Description |
|---|---|---|---|
ReCaptchaV3TaskProxyless |
all | Example | reCAPTCHA v3 |
ReCaptchaV3TaskProxylessS9 |
all | Example | reCAPTCHA v3, returns a token scored ≥ 0.9 |
ReCaptchaV3EnterpriseTaskProxyless |
all | Example | reCAPTCHA v3 Enterprise |
ReCaptchaV3EnterpriseTaskProxylessS9 |
all | Example | reCAPTCHA v3 Enterprise, scored ≥ 0.9 |
| Task type | Modes | Example | Description |
|---|---|---|---|
FuncaptchaTaskProxyless |
all | Example | FunCaptcha token |
FunCaptchaClassification |
sync | Example | Image classification for a FunCaptcha challenge |
| Task type | Modes | Example | Description |
|---|---|---|---|
HCaptcha |
all | Example | hCaptcha pass |
HCaptchaClassification |
sync | Example | Image classification for an hCaptcha challenge |
| Task type | Modes | Example | Description |
|---|---|---|---|
CloudFlareTurnstileTask |
all | Example | Turnstile token |
CloudFlare5STask |
all | Example | Five-second interstitial; returns browser state, not a token |
| Task type | Modes | Example | Description |
|---|---|---|---|
AkamaiWEBTaskProxyless |
sync | Example | Akamai Web sensor payload, one round at a time |
AkamaiSBSDTaskProxyless |
sync | Example | Akamai SBSD sensor payload |
| Task type | Modes | Example | Description |
|---|---|---|---|
DataDomeTaskProxyless |
sync | Example | DataDome challenge, both steps |
DataDomeTagsTaskProxyless |
sync | Example | DataDome tags payload |
| Task type | Modes | Example | Description |
|---|---|---|---|
PerimeterX |
all | Example | PerimeterX (Press & Hold) clearance cookies |
IncapsulaTaskProxyless |
sync | Example | Incapsula Reese84 sensor payload |
TlsTask |
sync | Example | Forward one HTTP request through the worker's TLS fingerprint |
npm install ezcapsolver-js
pnpm add ezcapsolver-js
yarn add ezcapsolver-js
bun add ezcapsolver-jsNode 22.12 or newer. The package ships both ESM and CommonJS builds with their own type declarations, so import and require both work:
import { EzCapSolverClient } from "ezcapsolver-js"; // ESM
const { EzCapSolverClient } = require("ezcapsolver-js"); // CommonJSTypeScript types are included; there is no @types/ package to install.
import { EzCapSolverClient } from "ezcapsolver-js";
// Reads EZCAPTCHA_API_KEY from the environment when no key is passed.
const client = new EzCapSolverClient({ clientKey: "your-client-key" });
const solved = await client.solveReCaptchaV2TaskProxyless({
websiteURL: "https://www.google.com/recaptcha/api2/demo",
websiteKey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
});
console.log(solved.solution.gRecaptchaResponse);
console.log(await client.getBalance());// Create the task, then poll until a worker finishes it.
const solved = await client.solveHCaptcha({ websiteURL, websiteKey });
// The synchronous endpoint, which answers on the creating request.
const solved = await client.syncSolveHCaptcha({ websiteURL, websiteKey });Both are asynchronous JavaScript. The difference is which service endpoint is used: solve creates a task and polls /getTaskResult, syncSolve posts to /createSyncTask and gets the answer back on that request.
Use the mode listed in the tables above when there is no reason to prefer the other — the service may reject a type on the endpoint it does not serve, and that rejection costs a round trip rather than a task.
Two formats are in play, and picking the wrong one is rejected:
| Format | Shape | Used by |
|---|---|---|
NORMAL |
protocol://username:password@host:port |
every type except FunCaptcha |
FUN |
protocol://host:port:username:password |
FuncaptchaTaskProxyless only |
protocol is one of http, https or socks5. Both credentials are required — the service rejects an unauthenticated proxy — and the host may not be a private address such as 127.0.*, 192.168.*, 172.16.* or 10.0.*.
This is the proxy the worker uses to reach the protected site. For routing the SDK's own traffic through a proxy, see Configuration.
The first five types share one parameter model and one solution model; only the method name differs.
const solved = await client.solveReCaptchaV2TaskProxyless({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_site_key",
});
console.log(solved.taskId);
console.log(solved.solution.gRecaptchaResponse);Identical parameters to plain v2; runs on the high-score queue and returns a token scored ≥ 0.9.
const solved = await client.solveReCaptchaV2TaskProxylessS9({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_site_key",
});Carries the challenge-bound s parameter. It is not mandatory; without it the behaviour matches plain v2.
const solved = await client.solveReCaptchaV2STaskProxyless({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_site_key",
s: "value-from-the-page",
});const solved = await client.solveReCaptchaV2EnterpriseTaskProxyless({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_enterprise_key",
});const solved = await client.solveReCaptchaV2SEnterpriseTaskProxyless({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_enterprise_key",
s: "value-from-the-page",
});Runs on the synchronous endpoint. size is the grid width: 1 for a single image, 3 or 4 for a grid.
import { isMultiClassification, isSingleClassification } from "ezcapsolver-js";
const solved = await client.syncSolveReCaptchaV2Classification({
image: base64EncodedImage,
question: "/m/014xcs", // Google object identifier; this one is "crosswalk"
size: 3,
});
const { solution } = solved;
if (isMultiClassification(solution)) {
console.log(solution.objects); // zero-based indexes of the cells to click
} else if (isSingleClassification(solution)) {
console.log(solution.hasObject);
}pageAction has to match the action the protected page grades against.
const solved = await client.solveReCaptchaV3TaskProxyless({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_site_key",
pageAction: "examples/v3scores",
});const solved = await client.solveReCaptchaV3TaskProxylessS9({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_site_key",
pageAction: "examples/v3scores",
});const solved = await client.solveReCaptchaV3EnterpriseTaskProxyless({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_enterprise_key",
pageAction: "examples/v3scores",
});The task catalog writes this one as RecaptchaV3EnterpriseTaskProxylessS9. The SDK normalises it to match the rest of the ReCaptcha family; the service matches task types case-insensitively, so both reach the same worker.
const solved = await client.solveReCaptchaV3EnterpriseTaskProxylessS9({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_enterprise_key",
pageAction: "examples/v3scores",
});Remember the FUN proxy format for this type, and note the lowercase c in the wire name.
const solved = await client.solveFuncaptchaTaskProxyless({
websiteURL: "https://example.com",
websiteKey: "your_public_key",
data: '{"blob":"..."}', // Arkose Labs blob, when the page produces one
proxy: "http://host:8080:user:pass",
});
console.log(solved.solution.token);The result shape of this type is unconfirmed. Every field the worker returns is on the solution object; read it with a bracket.
const solved = await client.syncSolveFunCaptchaClassification({
image: base64EncodedImage,
question: "Pick the image that is the correct way up",
});
console.log(solved.raw);const solved = await client.solveHCaptcha({
websiteURL: "https://accounts.hcaptcha.com/demo",
websiteKey: "a5f74b19-9e45-40e0-b45d-47ff91b7a6c2",
lang: "en-US",
invisible: false, // true on sites that show no checkbox
rqdata: process.env["EZCAPTCHA_HCAPTCHA_RQDATA"], // lower case, unlike Cloudflare's rqData
});
console.log(solved.solution.generated_pass_UUID);
console.log(solved.solution.ua); // requests carrying the pass must send this User-AgentThe shape is unconfirmed here too.
const solved = await client.syncSolveHCaptchaClassification({
image: base64EncodedImage,
question: "Please click each image containing a crosswalk",
});Cloudflare Turnstile API reference
const solved = await client.solveCloudFlareTurnstileTask({
websiteURL: "https://example.com",
websiteKey: "0x4AAAAAAA...", // Turnstile site keys start with 0x
});
console.log(solved.solution.token);There is no single token here. Replaying the headers and cookies against the protected site is what clears the challenge, which is why the whole browser state comes back. proxy is required for this type, and the replay has to go through the same proxy or the clearance is rejected.
const solved = await client.solveCloudFlare5STask({
websiteURL: "https://example.com",
proxy: "http://user:pass@host:8080",
});
const { header, cookies, tlsVersion } = solved.solution;A multi-round flow: feed each round's encodedata into the next round's encodeData and increment index. The round that ends the flow returns no further state.
let encodeData = "";
for (let index = 0; index < 5; index++) {
const solved = await client.syncSolveAkamaiWEBTaskProxyless({
pageUrl: "https://example.com",
v3Url: "https://example.com/v3/...",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0",
lang: "en-GB",
index,
abck: "_abck_cookie_value",
bmsz: "bm_sz_cookie_value",
script_base64: index === 0 ? base64EncodedScript : "",
encodeData,
});
encodeData = solved.solution.encodedata ?? "";
if (encodeData === "") break;
}const solved = await client.syncSolveAkamaiSBSDTaskProxyless({
pageUrl: "https://example.com",
sbsdUrl: "https://example.com/sbsd/xxxxx?v=xxx",
bmSo: "bm_so_cookie_value",
ua: "Mozilla/5.0 ...",
lang: "en-US",
script_base64: base64EncodedScript,
});
console.log(solved.solution.payload);Both steps return the same solution model: step one carries the challenge address in url, step two the validation instructions.
// Step one: find out where the challenge lives.
const first = await client.syncSolveDataDomeTaskProxyless({
html_b64: challengeHtmlBase64,
step: "1",
referer: "https://example.com",
});
// Step two: capture the slider image, then ask for the validation instructions.
const second = await client.syncSolveDataDomeTaskProxyless({
html_b64: challengeHtmlBase64,
step: "2",
image: base64EncodedSliderImage,
referer: first.solution.url,
});const solved = await client.syncSolveDataDomeTagsTaskProxyless({
ddk: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
jstype: "ch",
cid: "", // session identifier; empty is valid on first contact
bpc: 1, // one-based packet counter, incremented on every send
referer: "https://example.com",
ua: "Mozilla/5.0 ...",
});The clearance cookies come back as top-level fields, with the leading underscore their cookie names carry.
const solved = await client.solvePerimeterX({
websiteKey: "PXxxxxxxxx", // PerimeterX application identifier
invisible: false,
});
console.log(solved.solution._px3);data is stringified JSON and has to be submitted exactly as it arrived — parsing it would change what the sensor endpoint receives, so the SDK leaves it alone.
const solved = await client.syncSolveIncapsulaTaskProxyless({
script: reese84ScriptSource,
scriptUrl: "https://example.com/xxxxx?d=example.com",
pageUrl: "https://example.com",
acceptLanguage: "en-US,en;q=0.9",
ua: "Mozilla/5.0 ...",
pow: process.env["EZCAPTCHA_INCAPSULA_POW"],
});
console.log(solved.solution.data);const solved = await client.syncSolveTlsTask({
tls_type: "chrome146", // browser fingerprint the worker presents
proxy: "http://user:pass@host:8080",
method: "GET",
url: "https://example.com/api",
});
console.log(solved.solution.code, solved.solution.body);Parameters this release does not model go inline, next to the declared ones. The object you write is the task object that goes on the wire.
const solved = await client.solveReCaptchaV2TaskProxyless({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_site_key",
someNewParameter: "whatever the docs say",
aNumber: 42,
});extra does the same thing, for when keeping pass-through parameters visibly separate is worth the nesting:
await client.solveReCaptchaV2TaskProxyless({
websiteURL: "https://example.com",
websiteKey: "6Lc_your_site_key",
extra: { someNewParameter: "whatever the docs say" },
});Two things worth knowing:
- A
typepassed this way is ignored. The SDK writes it last, so a straytypecannot silently bill a different task. - Accepting arbitrary keys means TypeScript stops flagging a misspelled optional parameter — it is sent as a custom one instead. Required parameters and the types of declared ones are still checked.
There is no extra bucket on the way back. The solution object keeps every field the worker sent, so a declared field reads with a dot and an unmodelled one with a bracket:
const { solution } = solved;
solution.gRecaptchaResponse; // declared — typed
solution.user_agent; // declared — typed, may be undefined
solution["contextId"]; // not declared — typed `unknown`, narrow before use
Object.entries(solution); // everything the worker returned
solved.raw; // the untouched value, when the exact bytes matterA worker that starts returning a new field never loses it, with or without an SDK upgrade.
The generic form takes any type string with any parameters, so a type the service ships today is usable without waiting for an SDK release:
const solved = await client.solve({
type: "SomeBrandNewTaskType",
websiteURL: "https://example.com",
whateverTheDocsSay: 42,
});
// The synchronous endpoint takes the same shape.
await client.syncSolve({ type: "SomeBrandNewSyncType", image });For a modelled type, the generic and named forms are equivalent:
await client.solve({ type: "HCaptcha", websiteURL, websiteKey });
await client.solveHCaptcha({ websiteURL, websiteKey });createTask is the billed call. Everything after it is free, so persist the id before doing anything that might fail — the service holds a result for five minutes, and a replacement task is billed again.
const taskId = await client.createTask({
type: "ReCaptchaV2TaskProxyless",
websiteURL: "https://example.com",
websiteKey: "6Lc_your_site_key",
});
// One query, no waiting. `status` narrows the result.
const once = await client.getTaskResult(taskId);
if (once.status === "ready") {
console.log(once.solution); // guaranteed present on this branch
}
// Or poll until it finishes, with settings for this call only.
const result = await client.waitForResult(taskId, {
polling: { interval: 2_000, maxAttempts: 30 },
});Every option has a default shared with the Rust, Go and Python SDKs. Anything left out keeps its default.
const client = new EzCapSolverClient({
clientKey: process.env.EZCAPTCHA_API_KEY,
asyncBaseUrl: "https://api.ez-captcha.com",
syncBaseUrl: "https://sync.ez-captcha.com",
timeout: 30_000,
syncTimeout: 240_000,
polling: { interval: 3_000, maxAttempts: 50 },
appId: undefined,
userAgent: "my-app/1.0",
logger: undefined,
fetch: undefined,
});| Option | Default | Notes |
|---|---|---|
clientKey |
EZCAPTCHA_API_KEY |
The only value read implicitly |
asyncBaseUrl |
https://api.ez-captcha.com |
Asynchronous tasks and balance |
syncBaseUrl |
https://sync.ez-captcha.com |
Synchronous tasks — a separate deployment |
timeout |
30_000 ms |
One asynchronous or balance request |
syncTimeout |
240_000 ms |
One synchronous task request |
polling.interval |
3_000 ms |
Applied before every query, the first included |
polling.maxAttempts |
50 |
Wait ceiling of two and a half minutes |
appId |
unset | Developer application identifier |
userAgent |
ezcapsolver-js/<version> node/<version> |
|
logger |
none | See Logging |
fetch |
globalThis.fetch |
See below |
The two timeout budgets are deliberately separate. A synchronous call blocks until a worker answers and the service allows its slowest types 180 seconds; sharing one value would either cut that short — aborting a call that was already billed — or make a network fault take minutes to surface.
Everything checkable is checked at construction, so a bad setting throws before anything is billed.
client.config; // the effective configuration, with no clientKey on itThis is the one place the JavaScript SDK differs from the other three: there is no proxy option, because a proxy needs a dispatcher and only Node's undici supplies one. Depending on it would rule out Deno, Bun and Cloudflare Workers, so the SDK takes a fetch instead.
import { ProxyAgent } from "undici";
const dispatcher = new ProxyAgent("http://user:pass@proxy.example:8080");
const client = new EzCapSolverClient({
fetch: (url, init) => fetch(url, { ...init, dispatcher } as RequestInit),
});Node's global fetch honours init.dispatcher, so only the agent is needed. The same seam takes a tuned connection pool, or a stub in tests.
This is unrelated to the proxy parameter on a task, which is what the worker uses to reach the protected site.
Every failure is an EzCaptchaError carrying a kind that says which layer it belongs to. The layering is by what you can do about the failure, not by where it came from.
| Kind | Class | Meaning |
|---|---|---|
config |
EzCaptchaError |
Invalid client configuration. Retrying is pointless. |
transport |
TransportError |
The request never produced a response: DNS, TCP, TLS, timeout. |
api |
ApiError |
A structured error reported by the service. |
polling-exhausted |
PollingExhaustedError |
The task never finished within the budget. |
unexpected-response |
UnexpectedResponseError |
The response did not parse, or broke the contract. |
solution-decode |
SolutionDecodeError |
A raw solution did not fit its model. |
wait-interrupted |
WaitInterruptedError |
A task was created — and billed — but waiting for it failed. |
import {
isApiError,
isTransportError,
isPollingExhaustedError,
isDecodeError,
taskIdOf,
} from "ezcapsolver-js";
try {
await client.solveHCaptcha({ websiteURL, websiteKey });
} catch (error) {
// Ask this first: whether a billed task is still recoverable matters more
// than what broke, and it is one question whichever error carries the id.
const taskId = taskIdOf(error);
if (taskId !== undefined) {
const result = await client.waitForResult(taskId); // free; a new task is not
}
if (isApiError(error)) {
if (error.isAuthenticationError()) {
// Stop. The service counts these per key, and thirty within a minute
// earn a three-minute ban. Do not back off and retry.
} else if (error.isTerminal()) {
// The same request will fail the same way. Change it.
} else if (error.isRateLimited()) {
// Throttled. Both codes clear on their own, so ask again later.
}
console.log(error.errorCode, error.httpStatus, error.errors);
}
}Use the isX guards rather than instanceof. A package published in both ESM and CJS can be loaded twice in one process, producing two unrelated class objects; instanceof then fails for an error that genuinely came from this SDK. The guards check a Symbol.for brand, which is shared process-wide.
This SDK does not retry a request for you. Creating a task is billed and is not idempotent, and the service temporarily bans a key that repeats certain credential errors, so the retry policy belongs to you. isTerminal(), isAuthenticationError() and isRateLimited() provide the facts needed to decide.
A throttled poll is the one exception. ERROR_REQUEST_LIMIT and ERROR_REQUEST_BANNED refuse the query, not the task: the service turns the request away before it ever looks the task up, so the task is still queued and still billed. waitForResult spends the attempt and polls again rather than discarding a result that was about to arrive. Every other ApiError is the poll's answer and ends the wait. Note that isRateLimited() is narrower than !isTerminal(), which is also true of every unknown code — including the worker codes that report a task that genuinely failed.
errorId is the sole success criterion. Most business errors arrive as HTTP 500 and a failed task arrives as HTTP 200, so the status line is not what the SDK branches on — and ApiError.httpStatus is always readable from one place.
Logging is discarded unless a logger is supplied. The interface is pino-shaped, so a pino instance drops straight in:
import pino from "pino";
const client = new EzCapSolverClient({ logger: pino({ level: "trace" }) });For something smaller, consoleLogger needs no dependency:
import { consoleLogger, EzCapSolverClient } from "ezcapsolver-js";
const client = new EzCapSolverClient({ logger: consoleLogger("debug") });Any object with the right method shape works; every level is optional.
const client = new EzCapSolverClient({
logger: {
debug: (fields, message) => myLogger.debug({ ...fields }, message),
error: (fields, message) => myLogger.error({ ...fields }, message),
},
});| Level | What it carries |
|---|---|
trace |
Full request and response bodies, with clientKey and proxy replaced at any nesting depth |
debug |
Each polling attempt |
info |
Task created, task completed |
Bodies sit at trace on purpose: a DataDome html_b64 or an Akamai script_base64 runs to megabytes, and mixing them into debug would make that level unusable for watching the task lifecycle. Nothing is rendered below trace, so leaving it off costs nothing.
One client handles any number of concurrent calls. It holds no per-request state, and fetch pools connections underneath — a client per task only throws that pooling away.
// allSettled, not all: one failure must not discard tasks that were billed.
const results = await Promise.allSettled(
sites.map((site) => client.solveReCaptchaV2TaskProxyless(site)),
);
for (const result of results) {
if (result.status === "rejected") {
const taskId = taskIdOf(result.reason); // still recoverable
}
}Every call takes an AbortSignal, merged with that call's own timeout, so one signal cancels a whole fan-out:
const controller = new AbortController();
setTimeout(() => controller.abort(), 60_000);
await client.solveHCaptcha({ websiteURL, websiteKey }, { signal: controller.signal });The service applies its own rate limits per key, so cap the fan-out when the list is large — examples/concurrency.ts shows one way.
examples/ holds one file per task type, plus five about the SDK itself.
export EZCAPTCHA_API_KEY=your-client-key
# These two use the vendors' own demo pages and run as they are.
node --experimental-strip-types examples/recaptcha-v2/recaptcha-v2-task-proxyless.ts
node --experimental-strip-types examples/hcaptcha/hcaptcha.ts
# About the SDK itself rather than a task type
node --experimental-strip-types examples/client-setup.ts
node --experimental-strip-types examples/custom-parameters.ts
node --experimental-strip-types examples/logging-and-errors.ts
node --experimental-strip-types examples/concurrency.ts
node --experimental-strip-types examples/raw-usage.tsThe flag strips type annotations before running; Node 23.6 and later do that by default. The rest of the examples need page data captured from the target site — see examples/README.md for the full index.
pnpm install
pnpm test # 165 tests, none of which touch the real API
pnpm test:watch
pnpm test:coverage
pnpm lint # biome, fixing what it can
pnpm build # ESM + CJS + both sets of declarations
pnpm typecheck # src, tests and examples in one pass
pnpm verify:package # publint + are-the-types-wrongpnpm typecheck needs pnpm build first: the examples import the package by name, which resolves through exports to dist/, so they are checked against the artifact that actually ships.
Contributions are welcome — see CONTRIBUTING.md.