Getting Started

How Agencies Price CaptchaAI Into Client Projects

An agency can run several client projects on one CaptchaAI plan and bill each client fairly, provided it allocates threads rather than solves: give every client a reserved concurrency cap, meter the thread-seconds each one actually holds, and turn that ledger into an invoice line. One constraint shapes the whole setup. An account has a single API key, and CaptchaAI's Terms of Service tell users not to share or sell their unique ID, so on a shared agency plan the key stays on servers you operate and clients never receive it. Below you will find the allocation math on the ADVANCE plan ($90/month, 50 threads), a metering gateway in Python and Node.js, invoice wording, and the point at which a client should own its own account.

Price the thread, not the solve

CaptchaAI sells plans by concurrent threads, with unlimited solves per thread (the unlimited-solves model explains the mechanics). A thread is one CAPTCHA in flight. When that solve finishes, the thread takes the next task. The monthly price is the same whether your clients send ten thousand CAPTCHAs between them or ten million.

That makes the solve count a poor way to split the bill. Two things decide how much of the plan a client consumes, and a solve count ignores both:

  • How long each task holds a thread. Image CAPTCHAs typically solve in under 0.5 seconds, while reCAPTCHA v2 typically solves in under 60 seconds. On the solver's side, a client sending 40,000 image tasks can hold threads for less total time than a client sending 3,000 reCAPTCHA v2 tasks.
  • How many tasks run at once. A client that fires 30 tasks in parallel at 09:00 forces you to own 30 threads at 09:00, even if the pool sits idle by 09:15. Plans are sized for the peak, not the monthly total.

That leaves two units you can defend in front of a client's finance team:

  1. Reserved concurrency: the number of threads a client may hold at the same moment. It is what you actually have to buy, so it is the fairest basis for a fixed monthly fee.
  2. Thread-seconds: the sum, across every task a client ran, of the time from accepted submit to returned result. It measures real use, which suits clients who share a pool without a reservation.

A practical combination is to bill the reservation and report thread-seconds next to it. When a client keeps running into its cap, the numbers for a bigger reservation are already on the invoice.

Worked allocation on ADVANCE ($90/month, 50 threads)

Take three clients. Client A runs a nightly price check on its own storefront, client B runs QA suites against its staging sign-up forms, and client C pulls reports from a supplier portal it holds a data agreement with. You reserve 10, 20 and 15 threads for them and keep 5 threads as buffer.

The cost of one thread is $90 ÷ 50 = $1.80 per month. Three pricing lines follow from that:

Client Reserved threads At cost, $1.80 per thread Buffer shared, $2.00 per thread Retainer example, $3.00 per thread
A 10 10 × $1.80 = $18.00 10 × $2.00 = $20.00 10 × $3.00 = $30.00
B 20 20 × $1.80 = $36.00 20 × $2.00 = $40.00 20 × $3.00 = $60.00
C 15 15 × $1.80 = $27.00 15 × $2.00 = $30.00 15 × $3.00 = $45.00
Buffer 5 5 × $1.80 = $9.00, carried by the agency spread over A to C spread over A to C
Total 50 $90.00 $90.00 $135.00

How each column is built:

  • At cost. Clients cover $18.00 + $36.00 + $27.00 = $81.00, and the agency absorbs the $9.00 buffer as overhead.
  • Buffer shared. The 45 reserved threads carry the whole plan: $90 ÷ 45 = $2.00 per reserved thread, and $20.00 + $40.00 + $30.00 = $90.00 recovers it in full.
  • Marked-up retainer. The loaded $2.00 × 1.5 gives $3.00 per reserved thread, and $30.00 + $60.00 + $45.00 = $135.00, which is $45.00 a month above plan cost. That margin pays for monitoring, the launch buffer, and the risk of a mid-contract upgrade. The 1.5 multiplier is an illustration, not a benchmark or a revenue figure.

For clients on a shared pool with no reservation, split the plan by thread-seconds instead. Suppose September's ledger shows 180,000 thread-seconds for A, 540,000 for B and 280,000 for C, 1,000,000 in all. The shares are 18%, 54% and 28%, so the pass-through lines are $90 × 0.18 = $16.20, $90 × 0.54 = $48.60 and $90 × 0.28 = $25.20, which sum to $90.00.

Put that usage next to the capacity. Fifty threads over a 30-day month offer 50 × 2,592,000 = 129,600,000 thread-seconds, and the three clients used under 1% of it. That is normal, because the plan exists for the busiest hour. Share-of-use billing is a fair way to divide a fixed cost, but it tells you nothing about plan size: size the plan from peak concurrency, as the thread count guide walks through, and estimate new projects with the per-project cost budget.

Prepaying lowers the per-thread figure: semi-annual billing is 5% off and annual billing 10% off at the time of writing (current terms on the pricing page). It also commits you for six or twelve months while many client contracts run month to month, so do not pass the discount through unless the client commits for the same term. The annual plan guide covers the break-even.

A metering gateway in Python

Caps only hold if every task goes through one place. Run a single gateway process that owns the key, and have every client job call solver.solve(client_id, params) instead of calling CaptchaAI directly. The gateway below does four things:

  • At start-up it calls res.php with action=threadsinfo. A successful answer is a bare JSON object with no status/request wrapper, such as {"threads":"50","working_threads":0}, where threads is a string and working_threads an integer. The gateway refuses caps that add up to more than the plan's threads.
  • It keeps one threading.BoundedSemaphore per client. A client at its cap waits in its own queue, and no other client notices.
  • It submits to https://ocr.captchaai.com/in.php with json=1 (success is {"status":1,"request":"<task id>"}), waits, then polls res.php with action=get every 5 seconds while the answer is CAPCHA_NOT_READY, the API's spelling, without the T. A transient ERROR_INTERNAL_SERVER_ERROR while polling gets a 10-second pause instead of a failure.
  • It writes a ledger row for every accepted task, from a finally block, so failures, timeouts and polls that died on a network error are recorded too, because they held a thread as well.
"""metered_solver.py: per-client caps and thread-second metering for one CaptchaAI key."""
import csv
import json
import os
import sys
import threading
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone

API_KEY = os.environ.get("CAPTCHAAI_API_KEY", "YOUR_API_KEY")
BASE = "https://ocr.captchaai.com"
LEDGER = "captcha_ledger.csv"
FIELDS = ["client_id", "task_id", "method", "submitted_at", "thread_seconds", "slot_wait_seconds", "outcome"]
CAPS = {"client-a": 10, "client-b": 20, "client-c": 15}  # must sum to <= plan threads
RETRYABLE = {"ERROR_ZERO_BALANCE", "ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}


def call(path, params):
    data = urllib.parse.urlencode({"key": API_KEY, "json": 1, **params}).encode()
    with urllib.request.urlopen(f"{BASE}/{path}", data=data, timeout=30) as resp:
        text = resp.read().decode()
    try:
        body = json.loads(text)
    except ValueError:
        body = None
    # Some errors arrive as plain text even with json=1.
    return body if isinstance(body, dict) else {"status": 0, "request": text.strip()}


def threads_info():
    info = call("res.php", {"action": "threadsinfo"})
    if "threads" not in info:
        raise RuntimeError(f"threadsinfo failed: {info.get('request')}")
    return int(info["threads"]), int(info["working_threads"])


class MeteredSolver:
    def __init__(self, caps):
        self.plan_threads, _ = threads_info()  # also rejects a bad key
        if sum(caps.values()) > self.plan_threads:
            raise ValueError(f"caps total {sum(caps.values())} > {self.plan_threads} plan threads")
        self.caps = caps
        self.slots = {c: threading.BoundedSemaphore(n) for c, n in caps.items()}
        self.inflight = {c: 0 for c in caps}
        self.lock = threading.Lock()

    def _submit(self, params, attempts=6):
        for _ in range(attempts):
            sub = call("in.php", params)
            if sub.get("status") == 1:
                return str(sub["request"])
            code = sub.get("request")
            if code not in RETRYABLE:  # key, parameter and proxy errors: fix, never resend
                raise RuntimeError(f"submit rejected: {code}")
            if code == "ERROR_ZERO_BALANCE":
                threads, working = threads_info()
                if working < threads:
                    raise RuntimeError("ERROR_ZERO_BALANCE with free threads: plan expired or inactive")
            time.sleep(10)  # pool saturated, or a transient server error
        raise RuntimeError(f"submit still failing after {attempts} attempts: {code}")

    def solve(self, client_id, params, first_wait=15, timeout=180):
        asked = time.time()
        with self.slots[client_id]:  # blocks while this client is at its cap
            waited = time.time() - asked
            task_id = self._submit(params)
            submitted = time.time()
            outcome, res = "error", {}  # stays "error" if a poll raises (network, HTTP 5xx)
            with self.lock:
                self.inflight[client_id] += 1
            try:
                time.sleep(first_wait)
                while True:
                    res = call("res.php", {"action": "get", "id": task_id})
                    # GeeTest v3 may answer with a bare challenge/validate/seccode object
                    status = "ok" if res.get("status") == 1 or "validate" in res else res.get("request")
                    if status not in ("CAPCHA_NOT_READY", "ERROR_INTERNAL_SERVER_ERROR"):
                        outcome = status
                        break
                    if time.time() - submitted > timeout:
                        outcome = "timeout"
                        break
                    time.sleep(5 if status == "CAPCHA_NOT_READY" else 10)
            finally:
                with self.lock:
                    self.inflight[client_id] -= 1
                self._record(client_id, task_id, params["method"], submitted, waited, outcome)
        if outcome != "ok":
            raise RuntimeError(f"{client_id} task {task_id}: {outcome}")
        return res  # answer in "request", or "result" + "user_agent" for some types

    def _record(self, client_id, task_id, method, submitted, waited, outcome):
        row = {
            "client_id": client_id, "task_id": task_id, "method": method,
            "submitted_at": datetime.fromtimestamp(submitted, timezone.utc).isoformat(timespec="seconds"),
            "thread_seconds": round(time.time() - submitted, 1),
            "slot_wait_seconds": round(waited, 1), "outcome": str(outcome)[:60],
        }
        with self.lock:
            new_file = not os.path.exists(LEDGER)
            with open(LEDGER, "a", newline="", encoding="utf-8") as fh:
                writer = csv.DictWriter(fh, fieldnames=FIELDS)
                if new_file:
                    writer.writeheader()
                writer.writerow(row)


if __name__ == "__main__":
    site_key, page_url = os.environ.get("SITE_KEY"), os.environ.get("PAGE_URL")
    if API_KEY == "YOUR_API_KEY" or not site_key or not page_url:
        sys.exit("Set CAPTCHAAI_API_KEY, SITE_KEY and PAGE_URL (a reCAPTCHA v2 page you are authorized to automate).")
    solver = MeteredSolver(CAPS)
    job = {"method": "userrecaptcha", "googlekey": site_key, "pageurl": page_url}
    print("token:", solver.solve("client-a", job)["request"][:50] + "...")

A few details matter for billing accuracy:

  • Match the first wait to the CAPTCHA type. The 15-second default follows the documented initial wait for reCAPTCHA. For image CAPTCHAs, pass first_wait=5, the documented wait for normal image tasks, or an image-heavy client is billed for idle waiting on every task.
  • Know where the answer lives. Most types return it in request. reCAPTCHA v2 Enterprise, reCAPTCHA v3 Enterprise and Cloudflare Challenge return result plus a user_agent you must reuse, and CaptchaFox (beta) returns request plus a user_agent. The CaptchaAI API docs list the parameters for each type.
  • Read thread-seconds for what they are. The gateway measures from accepted submit to the poll that returned the answer, so every task counts at least its first wait, and each further poll adds 5 seconds. Next to a reCAPTCHA v2 solve that rounding is small. For image tasks it dominates: a solve that typically finishes in under 0.5 seconds is recorded as 5 seconds or more. The figure is exactly how long that client's slot in your pool was busy, which is what the cap protects, but it is not the solver's time.
  • Keep image-heavy clients comparable. When one client sends mostly image CAPTCHAs and another sends reCAPTCHA, bill both on reservation rather than on share of thread-seconds, or compare shares only within the same method value in the ledger. For normal image CAPTCHAs, CaptchaAI also documents a one-shot solve.php endpoint that takes the same body as in.php and returns the answer in the same request, which brings the recorded time close to the real one.

The monthly per-client CSV

At month end, roll the ledger into one row per client. The script takes the month and the plan price as arguments, so the same file works after an upgrade.

"""monthly_report.py: roll the ledger into one invoice-ready row per client.

Usage: python monthly_report.py 2026-09 90
"""
import csv
import sys
from collections import defaultdict

month, plan_price = sys.argv[1], float(sys.argv[2])
tasks, seconds, waits = defaultdict(int), defaultdict(float), defaultdict(float)
with open("captcha_ledger.csv", newline="", encoding="utf-8") as fh:
    for row in csv.DictReader(fh):
        if row["submitted_at"].startswith(month):
            client = row["client_id"]
            tasks[client] += 1
            seconds[client] += float(row["thread_seconds"])
            waits[client] += float(row["slot_wait_seconds"])

total = sum(seconds.values()) or 1.0
with open(f"captcha_usage_{month}.csv", "w", newline="", encoding="utf-8") as fh:
    out = csv.writer(fh)
    out.writerow(["client_id", "tasks", "thread_seconds", "share_of_plan", "cost_at_share_usd", "slot_wait_hours"])
    for client in sorted(seconds):
        share = seconds[client] / total
        out.writerow([client, tasks[client], round(seconds[client]), f"{share:.1%}",
                      f"{plan_price * share:.2f}", round(waits[client] / 3600, 1)])
print(f"wrote captcha_usage_{month}.csv for {len(seconds)} clients")

With the September figures from the worked example, the file looks like this:

client_id tasks thread_seconds share_of_plan cost_at_share_usd slot_wait_hours
client-a 9412 180000 18.0% 16.20 0.0
client-b 12080 540000 54.0% 48.60 3.1
client-c 6950 280000 28.0% 25.20 0.4

The last column is the one that starts a conversation. Client B's tasks spent 3.1 hours in total waiting for a free slot inside its own 20-thread reservation. That is the cap doing its job, since clients A and C never noticed B's bursts, and it is also the evidence for offering B a larger reservation.

The same gateway in Node.js

The Node.js version keeps the same ledger format, so one month-end report covers both. It needs Node.js 18 or later for the built-in fetch, and because Node runs your code on one thread, a small promise-based semaphore replaces the locks.

// metered.mjs (Node.js 18+): per-client caps and thread-second metering for one CaptchaAI key.
// Solve:  node metered.mjs
// Report: node metered.mjs report 2026-09 90
import fs from "node:fs";

const API_KEY = process.env.CAPTCHAAI_API_KEY ?? "YOUR_API_KEY";
const BASE = "https://ocr.captchaai.com";
const LEDGER = "captcha_ledger.csv";
const CAPS = { "client-a": 10, "client-b": 20, "client-c": 15 }; // must sum to <= plan threads
const RETRYABLE = new Set(["ERROR_ZERO_BALANCE", "ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function call(path, params) {
  const body = new URLSearchParams({ key: API_KEY, json: "1", ...params });
  const resp = await fetch(`${BASE}/${path}`, { method: "POST", body });
  if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${path}`);
  const text = await resp.text();
  try {
    const parsed = JSON.parse(text);
    if (parsed && typeof parsed === "object") return parsed;
  } catch {
    // not JSON: a plain-text error code, even with json=1
  }
  return { status: 0, request: text.trim() };
}

async function threadsInfo() {
  const info = await call("res.php", { action: "threadsinfo" });
  if (info.threads === undefined) throw new Error(`threadsinfo failed: ${info.request}`);
  return { threads: Number(info.threads), working: Number(info.working_threads) };
}

class Semaphore {
  constructor(size) { this.free = size; this.queue = []; }
  async acquire() {
    if (this.free > 0) { this.free -= 1; return; }
    await new Promise((resolve) => this.queue.push(resolve));
  }
  release() {
    const next = this.queue.shift();
    if (next) next(); else this.free += 1;
  }
}

class MeteredSolver {
  static async create(caps) {
    const { threads } = await threadsInfo(); // also rejects a bad key
    const total = Object.values(caps).reduce((a, b) => a + b, 0);
    if (total > threads) throw new Error(`caps total ${total} > ${threads} plan threads`);
    return new MeteredSolver(caps);
  }

  constructor(caps) {
    this.caps = caps;
    this.slots = Object.fromEntries(Object.entries(caps).map(([c, n]) => [c, new Semaphore(n)]));
    this.inflight = Object.fromEntries(Object.keys(caps).map((c) => [c, 0]));
  }

  async submit(params) {
    for (let attempt = 1; attempt <= 6; attempt += 1) {
      const sub = await call("in.php", params);
      if (sub.status === 1) return String(sub.request);
      if (!RETRYABLE.has(sub.request)) throw new Error(`submit rejected: ${sub.request}`);
      if (sub.request === "ERROR_ZERO_BALANCE") {
        const { threads, working } = await threadsInfo();
        if (working < threads) throw new Error("ERROR_ZERO_BALANCE with free threads: plan expired or inactive");
      }
      await sleep(10_000); // pool saturated, or a transient server error
    }
    throw new Error("submit still failing after 6 attempts");
  }

  async solve(clientId, params, { firstWait = 15, timeout = 180 } = {}) {
    const asked = Date.now();
    await this.slots[clientId].acquire(); // waits while this client is at its cap
    try {
      const waited = (Date.now() - asked) / 1000;
      const taskId = await this.submit(params);
      const submitted = Date.now();
      let res = {};
      let outcome = "error"; // stays "error" if a poll throws (network, HTTP 5xx)
      this.inflight[clientId] += 1;
      try {
        await sleep(firstWait * 1000);
        for (;;) {
          res = await call("res.php", { action: "get", id: taskId });
          // GeeTest v3 may answer with a bare challenge/validate/seccode object
          const status = res.status === 1 || "validate" in res ? "ok" : res.request;
          if (status !== "CAPCHA_NOT_READY" && status !== "ERROR_INTERNAL_SERVER_ERROR") { outcome = status; break; }
          if (Date.now() - submitted > timeout * 1000) { outcome = "timeout"; break; }
          await sleep(status === "CAPCHA_NOT_READY" ? 5000 : 10_000);
        }
      } finally {
        this.inflight[clientId] -= 1;
        record({ clientId, taskId, method: params.method, submitted, waited, outcome });
      }
      if (outcome !== "ok") throw new Error(`${clientId} task ${taskId}: ${outcome}`);
      return res; // answer in request, or result + user_agent for some types
    } finally {
      this.slots[clientId].release();
    }
  }

  async poolStatus() {
    const { threads, working } = await threadsInfo();
    const local = Object.values(this.inflight).reduce((a, b) => a + b, 0);
    const atCap = Object.keys(this.caps).filter((c) => this.inflight[c] >= this.caps[c]);
    return { threads, working, outsideGateway: Math.max(0, working - local), atCap };
  }
}

function record({ clientId, taskId, method, submitted, waited, outcome }) {
  if (!fs.existsSync(LEDGER)) {
    fs.writeFileSync(LEDGER, "client_id,task_id,method,submitted_at,thread_seconds,slot_wait_seconds,outcome\n");
  }
  const row = [clientId, taskId, method, new Date(submitted).toISOString(),
    ((Date.now() - submitted) / 1000).toFixed(1), waited.toFixed(1),
    String(outcome).slice(0, 60).replace(/[,\r\n]/g, " ")]; // keep the CSV row intact
  fs.appendFileSync(LEDGER, row.join(",") + "\n");
}

function monthlyReport(month, planPrice) {
  const usage = {};
  for (const line of fs.readFileSync(LEDGER, "utf8").trim().split("\n").slice(1)) {
    const [client, , , submittedAt, secs, wait] = line.split(",");
    if (!submittedAt.startsWith(month)) continue;
    usage[client] ??= { tasks: 0, secs: 0, wait: 0 };
    usage[client].tasks += 1;
    usage[client].secs += Number(secs);
    usage[client].wait += Number(wait);
  }
  const total = Object.values(usage).reduce((sum, u) => sum + u.secs, 0) || 1;
  const out = ["client_id,tasks,thread_seconds,share_of_plan,cost_at_share_usd,slot_wait_hours"];
  for (const [client, u] of Object.entries(usage).sort(([a], [b]) => a.localeCompare(b))) {
    const share = u.secs / total;
    out.push([client, u.tasks, Math.round(u.secs), `${(share * 100).toFixed(1)}%`,
      (planPrice * share).toFixed(2), (u.wait / 3600).toFixed(1)].join(","));
  }
  fs.writeFileSync(`captcha_usage_${month}.csv`, out.join("\n") + "\n");
  console.log(`wrote captcha_usage_${month}.csv for ${Object.keys(usage).length} clients`);
}

if (process.argv[2] === "report") {
  monthlyReport(process.argv[3], Number(process.argv[4]));
} else {
  const { SITE_KEY, PAGE_URL } = process.env;
  if (API_KEY === "YOUR_API_KEY" || !SITE_KEY || !PAGE_URL) {
    console.error("Set CAPTCHAAI_API_KEY, SITE_KEY and PAGE_URL (a reCAPTCHA v2 page you are authorized to automate).");
    process.exit(1);
  }
  const solver = await MeteredSolver.create(CAPS);
  const res = await solver.solve("client-a", { method: "userrecaptcha", googlekey: SITE_KEY, pageurl: PAGE_URL });
  console.log(`token: ${String(res.request).slice(0, 50)}...`);
  console.log(await solver.poolStatus());
}

Both versions keep their semaphores in memory, so the caps hold only while one gateway process serves the key. If jobs run on several hosts, move the per-client counters into shared storage; the semaphore patterns guide shows the distributed variants.

Finding who saturated the shared pool

The pool is saturated when threadsinfo reports working_threads equal to or above threads. Compare that with the gateway's own in-flight counts and each case points somewhere specific. This watcher runs in the same process as the Python gateway, because it reads the solver's counters:

"""pool_watch.py: log shared-pool saturation and who is at their cap."""
import threading
import time

from metered_solver import CAPS, MeteredSolver, threads_info


def pool_status(solver):
    threads, working = threads_info()
    with solver.lock:
        local = dict(solver.inflight)
    return {
        "plan_threads": threads,
        "working_threads": working,
        "outside_gateway": max(0, working - sum(local.values())),
        "at_cap": [c for c, n in local.items() if n >= solver.caps[c]],
    }


def watch(solver, interval=30):
    while True:
        status = pool_status(solver)
        if status["working_threads"] >= status["plan_threads"] or status["outside_gateway"]:
            print(time.strftime("%H:%M:%S"), status, flush=True)
        time.sleep(interval)


if __name__ == "__main__":
    shared = MeteredSolver(CAPS)
    threading.Thread(target=watch, args=(shared,), daemon=True).start()
    # hand `shared` to your job runner here; this demo only watches for ten minutes
    time.sleep(600)

Reading the output:

  • A client listed in at_cap is queueing inside its own reservation. The other clients are unaffected, and the ledger's slot_wait_seconds column shows how much it cost that client in time.
  • A persistent outside_gateway above zero means something is using the key without going through the gateway: a forgotten deployment, a developer's test script, or a key that reached a client. The figure errs low, because the gateway counts a task until its poll returns while CaptchaAI frees the thread when the solve ends, so any value that stays positive across several checks is real. The account has one key, so regenerating it in the dashboard cuts off the stray user and every legitimate integration at once. Update the gateway's secret in the same change window.
  • ERROR_ZERO_BALANCE from in.php means there are no free threads or no active plan. It is not about money. Both gateways already diagnose it with threadsinfo: when every thread is busy they wait and resubmit a bounded number of times, and when threads are free they stop, because the plan has expired or is inactive. The error codes reference covers the rest of the list.

Do not rely on that error to spot saturation. Comments in CaptchaAI's official Python SDK say the API can queue tasks silently when all threads are busy, so a saturated pool may only show up as longer CAPCHA_NOT_READY polling. Watch threadsinfo, not the error log.

Invoice lines that survive a finance review

Whichever model you pick, write the cap into the line so the client knows its limit and attach the monthly CSV. Keep promised solve volumes, success rates and speeds out of the line, because none of them is yours to promise.

Model Example invoice line Fits
Pass-through at cost "CAPTCHA solving capacity, September 2026: 10 reserved concurrent solves on our CaptchaAI subscription, at cost including shared buffer: $20.00. Usage report attached." Clients who audit vendor costs line by line
Bundled into a retainer "Automation operations retainer, September 2026. Includes CAPTCHA handling for up to 20 concurrent solves; usage report attached." Managed-service clients who buy an outcome
Fixed CAPTCHA handling line "CAPTCHA handling (capacity for up to 15 concurrent solves, monitoring and incident response): $45.00/month." Clients who want one predictable number

Shared agency plan or a client-owned account

Two facts decide this. First, the dashboard's API page shows one key per account, with an option to regenerate it, and no sub-account or multi-key feature is documented. The same page tells you to keep the key secret and not share it with anyone. A per-client key therefore means a client-owned account. Second, the Terms of Service tell users not to share or sell their unique ID, so an integration that has to run on the client's own servers belongs on the client's own account. Reselling raw API access or white-labelling it is a separate question for those same terms; read them before you offer either.

Question Shared agency plan Client-owned account
Who holds the API key The agency; it never leaves agency-run servers The client; it can give the key to whichever vendor runs the work
Where the integration runs Only on infrastructure the agency operates Anywhere, including the client's servers
Handoff and offboarding The key cannot transfer, so the client opens its own account before exit and the integration is re-pointed The client regenerates its key after the agency leaves; nothing to migrate
Key rotation One key: regenerating it re-keys every client at once Affects only that client
Volume spikes Absorbed by the buffer; beyond that, the agency pays for a plan change The client's own threads; the client decides whether to upgrade
Data and compliance boundary CaptchaAI sees one agency account for all clients' target sites; the agency ledger holds per-client usage Each client has its own vendor relationship, billing record and usage history
Best fit Small or steady clients on a managed service Clients with their own infrastructure, a compliance team, or peaks above your buffer

If clients do open their own accounts, the CaptchaAI dashboard also has a Partnership section where you can apply for an affiliate link; its current commission terms are shown there once your application is approved.

Contract clauses to settle before the first solve

  • Authorization warranty. The client warrants that it owns, or is authorized to automate, every target flow listed in an annex to the statement of work (URL, form or endpoint, and purpose), and it notifies the agency before a new flow is added.
  • Scope. The agency runs integrations only on client-owned apps and sites, or on third-party sites under a written agreement the client holds with that site's operator. No third-party accounts, and no data the client has no right to collect.
  • Capacity. The client's reserved concurrency is stated as a number. Tasks above it queue rather than fail, and changes to the reservation go through a written change order.
  • Key custody. The API key stays with the agency and is never delivered. On termination the agency stops routing tasks by an agreed date, and a client that wants continuity opens its own account before that date.
  • Price changes. If the agency's plan price changes, the per-thread rate follows from the next billing cycle. Prepay discounts stay with the agency unless the client commits for the same term.
  • Usage data. The ledger holds client IDs, task IDs, CAPTCHA types, timestamps and outcomes, and no page content. State how long you keep it.

FAQ

Can I give each client its own API key under my CaptchaAI account?

Not with any documented feature. The dashboard shows one key per account and a regenerate option. If a client needs its own key, it needs its own account; if it stays on your plan, per-client separation lives in your gateway's caps and ledger.

A client needs more than its reserved threads for a launch week. What do I do?

Lend it buffer threads by raising its cap in the gateway config, and bill the extra reservation for that month. If the caps would sum past 50, move up to PREMIUM ($170/month, 100 threads), or read how stacking plans for concurrency compares, and reprice the per-thread rate from the new plan.

Should failed or timed-out tasks count toward a client's usage?

Yes. They held a thread, so the ledger records them with their outcome. If a failure came from a bug in your own integration, credit it on the invoice rather than deleting ledger rows; the raw ledger should stay complete.

How do I price a new client before there is any usage data?

Estimate peak concurrency from a test run: solves per peak hour × average hold time in seconds ÷ 3,600. For example, 1,200 reCAPTCHA v2 tasks in the busiest hour at a measured average hold of 40 seconds gives 1,200 × 40 ÷ 3,600 = 13.3 threads on average in that hour. Reserve above that average, for instance 20, because arrivals within the hour are uneven, then check the first month's slot_wait_hours and adjust.

Next step

Compare the thread plans on the CaptchaAI pricing page, size the plan for the sum of your client reservations plus a buffer, and put the metering gateway in front of the key before the first client goes live.

Discussions (0)

No comments yet.