How to Provision CAPTCHA API Keys for Your Customers

If you run CapSkip for other people, you can provision CAPTCHA API keys the moment a customer pays, straight from your billing webhook. Remote Key Management exposes POST endpoints that add, list, delete, limit and meter keys on the running instance, and a new key is live on the very next solving request. No restart, no manual step, and nothing sitting between a cleared payment and a customer who can start solving. Each key can also carry its own credit limit per CAPTCHA type, so a plan tier becomes a number the solver enforces instead of a rule your billing system has to police. This post covers minting a key on subscribe, setting and topping up those limits, metering what each customer used, revoking on cancel, and the one networking mistake this setup makes easy.
What you need
- CapSkip running on a Windows machine you control. That box is your solving service, and it is the only machine that needs the application installed. Your customers never install anything.
- Server mode, so those customers can actually reach it. Local mode binds to the loopback address and serves that device only, while Server mode binds to your network or public IP so other machines can connect. Both are described under connection settings, and a static public IP is worth having before you hand the address to anyone.
- One visit to the CapSkip window to generate the admin token. That is the only step that happens in the interface, and you never repeat it.
- Somewhere to run the calls from: your billing webhook handler, your dashboard backend, or a terminal while you are testing.
Worth stating plainly before the code, because it is the part that makes this model work at all. The keys you are handing out are yours to mint. They are not credits you bought from a vendor and are reselling, and minting one costs you nothing. CapSkip runs on your hardware, so a hundred customer keys cost exactly what one costs.
What a key can now carry is its own credit limit, set separately for each CAPTCHA type. That turns it from a bare label into a plan tier the solver itself enforces: “Starter, 10,000 reCAPTCHA solves” becomes a number CapSkip counts down rather than something you have to watch for. There are eight billing buckets, and they are deliberately coarser than the solver’s task types:
- The ImageToText bucket covers image and OCR solving.
- The Recaptcha bucket covers v2, v3 and both Enterprise variants together.
- The Turnstile bucket covers Cloudflare Turnstile.
- The GeeTest bucket covers GeeTest.
- The Altcha bucket covers ALTCHA.
- The CaptchaFox bucket covers CaptchaFox.
- The Capy bucket covers Capy Puzzle.
- The FriendlyCaptcha bucket covers Friendly Captcha.
A bucket with no limit set is unlimited, which is how every key behaved before this existed. So upgrading CapSkip changes nothing on its own: every key you already issued stays unlimited in all eight buckets until you set a limit, and an add call that says nothing about limits still creates an unlimited key.
Step 1: turn on Remote Key Management
The switch is in Settings, under API access, then Remote Key Management. Turn it on and press the Generate button.
The token is shown once and stored only as a salted hash, so there is nothing to read back later. Lose it and you generate a new one, which invalidates the old immediately. To shut the admin API off without issuing a new token, press Revoke, which clears the stored token. Put it in the same place your billing system keeps its other secrets, not in your application code.
The endpoints live on the same host and port that already answers your solving requests. If your customers talk to port 8080, so does the admin API. That is convenient and it is also the thing you have to be careful about, which is its own section further down.
While the feature is off, every admin path returns a plain 404 with no body. That is the same response an unknown path gets, and it is deliberate: someone probing the port cannot tell the difference between an instance that has this disabled and one that has never heard of it.
Step 2: mint a key when a customer subscribes
Every endpoint is POST, takes a JSON body, and requires an Authorization header carrying the token as a bearer credential. The add call needs a name. Send only the name and CapSkip generates the key value for you, which is what you want here: the customer never picks their own credential. Add a grants object and the key arrives with the customer’s plan limits already on it.
# No install needed. Name the key after the customer, not after the plan.
curl -X POST http://YOUR_SERVER:8080/admin/keys/add \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"cust_10482","grants":{"Recaptcha":10000,"ImageToText":50000}}'{
"errorId": 0,
"key": {
"name": "cust_10482",
"key": "a1b2c3d4e5f6...",
"grants": {
"ImageToText": 50000,
"Recaptcha": 10000,
"Turnstile": null,
"GeeTest": null, "Altcha": null, "CaptchaFox": null, "Capy": null, "FriendlyCaptcha": null
}
}
}The response reports all eight buckets, not just the two you set, so you never need a follow-up read to confirm what you created. Name the key after something stable in your own system, like the customer id or the subscription id. Names are unique and that rule is enforced on add, so the name becomes a reliable handle for every later operation. Naming keys after plans or dates instead is the mistake that makes cancellation day painful.
Four rules govern the grants object, and the last one is the one that saves you:
- Omit it entirely and the key is unlimited everywhere. That is the pre-existing behaviour, so integrations written before quotas existed keep working untouched.
- Omit one bucket, or send it as a JSON null, and that bucket is unlimited. Send zero and that bucket starts exhausted instead.
- Bucket names are case-insensitive, but credits must be a bare JSON integer of zero or more, never a quoted string and never a decimal.
- The whole request is validated first, and only then is the key created. An unknown bucket name or a bad number returns a 400 and creates nothing, so a rejected request can never leave a half-limited key behind.
Set the limits at creation rather than creating the key and restricting it afterwards. The second approach leaves a window, however short, in which a brand new customer key is live with no limits at all.
That uniqueness rule is also your idempotency guard. Payment providers retry webhooks, and a retried subscribe event comes back as ERROR_KEY_EXISTS with a 409 rather than quietly creating a second key for the same customer. Treat that 409 as success in your handler and the replay problem disappears.
The value comes back exactly once, in that response. Show it to the customer or store it wherever your dashboard reads from, because listing the keys later is an admin operation and not something you want to run to recover one customer’s credential.
Step 3: adjust limits on upgrade or renewal
The grant endpoint writes one bucket’s limit on one key. Select the key by name or by value, exactly one of the two, the same way delete does.
# Upgrade: replace the limit outright.
curl -X POST http://YOUR_SERVER:8080/admin/keys/grant \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"cust_10482","bucket":"Recaptcha","credits":25000,"mode":"set"}'
# Top-up: add to whatever is left.
# -d '{"name":"cust_10482","bucket":"Recaptcha","credits":10000,"mode":"add"}'The two modes are not symmetrical. Set mode replaces the number outright, and a null or omitted credits field under set mode clears the limit back to unlimited. That is the only way to remove a limit once one exists. Worth noticing that a credits value of zero means the opposite of null: it exhausts the bucket rather than clearing it. Add mode computes the existing value plus your number, treating unlimited as zero, so it establishes a limit on a bucket that did not have one. It requires a credits value above zero.
Three request shapes are refused outright rather than acted on, each because the failure would otherwise be silent:
- Adding zero. A nightly top-up script computing target minus current produces zero for every customer already at target. Against an unlimited bucket that would establish a limit of exactly zero and stop the key solving, so use set mode when you mean zero.
- Negative numbers, in either mode. Adding minus 500 reads as a deduction on a limited bucket, but the identical request against an unlimited bucket floors at zero and quietly turns it into an exhausted one. The caller cannot predict which they will get, so neither is allowed.
- Quoted numbers. Sending the credits value as a quoted string means “cap this key at that number”, but treated as a missing value it would mean the exact opposite, clear the limit, and still return success. Quoting numbers is exactly what a shell script does by accident, so send credits unquoted.
One thing to plan for: a grant is a credit balance, not a monthly allowance. Nothing inside CapSkip is on a calendar. For a metered monthly plan, call the grant endpoint in set mode with the plan allowance at the start of each billing cycle, from your renewal webhook. For a plan where unused credits roll over, use add mode with the cycle allowance instead.
Step 4: meter what each customer used
The usage endpoint takes an empty object and returns per-key, per-bucket consumption for every key at once.
# Every key, every bucket, in one call.
curl -X POST http://YOUR_SERVER:8080/admin/keys/usage \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" -d '{}'{
"errorId": 0,
"keys": [
{
"name": "cust_10482",
"key": "a1b2c3d4e5f6...",
"buckets": [
{
"bucket": "Recaptcha",
"granted": 10000,
"remaining": 9581,
"used": 419,
"failed": 12,
"inFlight": 3,
"lastUsed": "2026-08-26T09:14:02.1234567Z"
}
]
}
]
}| Field | Meaning |
|---|---|
| "granted" | The limit, or null for unlimited. |
| "remaining" | Credits left, or null when the bucket is unlimited. |
| "used" | Successful solves. The only thing that spends credit. |
| "failed" | Failed attempts, counted for diagnostics but never charged. |
| "inFlight" | Tasks reserved right now. Already deducted from remaining, and returned if they fail. |
| "lastUsed" | ISO-8601 UTC of the last successful solve, or null if the key has never been used. |
Counters are cumulative for the life of the key and survive restarts. Because one call returns every key, poll it on a schedule to feed a dashboard rather than calling it per customer. The lastUsed field is also how you find dormant keys worth reclaiming.
Step 5: revoke it when they cancel
Deleting takes exactly one selector, either the key value or the name. Because you named keys after customers, the name is the one to use:
# By name, which is unambiguous because names are unique.
curl -X POST http://YOUR_SERVER:8080/admin/keys/delete \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" -d '{"name":"cust_10482"}'
# By value works too, if that is what your records hold:
# -d '{"key":"the-old-value"}'Revocation takes effect on the next solving request, so access ends the instant the call returns. From then on that customer’s calls fail with ERROR_KEY_DOES_NOT_EXIST, which is a clear enough signal that their integration can surface it as an expired subscription rather than an outage.
Decide deliberately when to fire this. Deleting on the cancellation event cuts access immediately, even though the customer has usually paid through the end of the period. Deleting on the period-end event is what most people actually want. A failed payment is a third case: a short grace window before revocation causes far fewer support tickets than a key that vanishes on a card retry.
Step 6: reconcile the list against your billing system
The list call takes an empty object and returns every key the instance currently accepts.
# The audit: exactly who can solve right now.
curl -X POST http://YOUR_SERVER:8080/admin/keys/list \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" -d '{}'{
"errorId": 0,
"keys": [
{ "name": "cust_10482", "key": "..." },
{ "name": "cust_10515", "key": "..." }
]
}An errorId of 0 means success, the same convention the solving endpoints use. Run this on a schedule and diff the names against your active subscriptions. A key with no matching subscription is someone still solving for free, and a subscription with no key is a customer whose provisioning webhook was dropped and who is probably about to open a ticket. Both are silent failures until you look, and this call is the only place that truth lives.
What your customers see when a limit is reached
A key with no remaining credit in a bucket gets ERROR_ZERO_BALANCE from the solving endpoints, for tasks in that bucket only. Every other bucket on that key keeps working. The error comes from the solving API rather than this admin API, and it arrives the same way across the 2captcha, Anti-Captcha, CapSolver and DeathByCaptcha emulations, so whatever handling a customer already has for a depleted 2captcha balance covers it without changes on their side.
Balance checks behave sensibly too. A customer polling getBalance, or the res.php balance action, sees their real remaining credits summed across every limited bucket, so the number counts down as they use it. A key with no limits anywhere keeps reporting the old 999999.99 sentinel, which means clients that sanity-check a balance before submitting keep working exactly as they did.
One blind spot is worth knowing about. The wire format has room for a single number, so a key with 50,000 image credits and zero reCAPTCHA credits still reports a healthy balance. Anything that needs the per-type breakdown has to read the usage endpoint instead, which is admin-only.
Wiring it into a subscription webhook
Put together, the whole integration is a plan table and four handlers:
# pip install requests
import requests
BASE = "http://YOUR_SERVER:8080"
AUTH = {"Authorization": "Bearer YOUR_TOKEN"}
BUCKETS = ("ImageToText", "Recaptcha", "Turnstile", "GeeTest", "Altcha", "CaptchaFox", "Capy", "FriendlyCaptcha")
# None means unlimited; an omitted bucket is unlimited too.
PLANS = {
"starter": {"ImageToText": 50_000, "Recaptcha": 10_000},
"pro": {"ImageToText": 500_000, "Recaptcha": 100_000,
"Turnstile": 25_000},
"unlimited": {},
}
def admin(path, body):
r = requests.post(f"{BASE}/admin/keys/{path}", json=body,
headers=AUTH, timeout=10)
if r.status_code == 409:
return None # already exists: a retried webhook
r.raise_for_status()
return r.json()
def on_subscription_active(customer_id, plan):
body = {"name": f"cust_{customer_id}"}
if PLANS[plan]:
body["grants"] = PLANS[plan]
created = admin("add", body)
return created["key"]["key"] if created else None
def apply_plan(customer_id, plan):
"""Upgrade, downgrade, or start-of-cycle reset."""
limits = PLANS[plan]
for bucket in BUCKETS:
admin("grant", {"name": f"cust_{customer_id}", "bucket": bucket,
"mode": "set", "credits": limits.get(bucket)})
def on_subscription_ended(customer_id):
admin("delete", {"name": f"cust_{customer_id}"})
def usage_report():
return admin("usage", {})["keys"]Notice that apply_plan writes every bucket rather than patching only the ones that changed. That is deliberate: on a downgrade it clears limits the old plan had and the new one does not, because a missing credits value under set mode means unlimited. For a rollover plan, switch it to add mode and skip the buckets whose limit is None, since add mode refuses a missing or zero value.
Handle the None from a replayed subscribe deliberately. It means the key exists but you cannot see its value any more, so if you failed to store it the first time, the recovery is to delete and re-add rather than to list. Storing the value when you first receive it avoids the whole situation.
Do not leave the admin API on the customer-facing port
This is the section that matters most for this setup, because the convenience of one host and port cuts both ways. Your customers need to reach the solving endpoints. The admin endpoints are on that same port, protected by nothing but the bearer token, and there is no transport check, so on a custom port that token travels in cleartext HTTP. Anyone positioned to watch the traffic can read it, and anyone holding it can mint themselves a key.
Put a reverse proxy in front of the instance and split the two audiences:
- Publish only the solving paths to the internet, over HTTPS terminated at the proxy, and return a 404 for anything under the admin path.
- Reach the admin endpoints from your own backend over a private route: the loopback address if your billing service runs on the same Windows box, otherwise a private network, a VPN, or an SSH tunnel.
The rule of thumb is that the admin token should never leave your infrastructure, and the port it talks to should never be one a customer can reach. Never expose the admin path to the open internet, with or without the feature enabled.
One more boundary worth knowing, since customer dashboards are the obvious thing to build here. Browsers cannot call these endpoints, because admin responses carry no CORS headers by design. Your dashboard has to go through your own backend, which is where the token belongs anyway.
Where the keys actually get written
Keys go wherever your keys already live. Direct Input and From File are separate lists, and the API reads and writes whichever one is currently selected. Switching the source in the settings window changes what the endpoints see, so confirm which mode you are in before you start wondering why a key you just provisioned is not in the list.
File mode has one behaviour worth knowing in advance: the first write rewrites a plain-text keys file as JSON. Nothing is lost, but the format changes permanently, so back the file up first if anything else reads it. If no file is selected at all, a write cannot land and you get ERROR_ADMIN_STORE_FAILURE with a 500. That is almost always what a 500 here means.
Lists created before this feature existed can still hold duplicate names, because uniqueness is only enforced on add. Delete by name against one of those returns ERROR_KEY_NAME_AMBIGUOUS and changes nothing. Delete it by value instead and the ambiguity disappears.
Windows shell quoting, if you are testing from the box itself
CapSkip is a Windows application, so the machine you are testing from is often Windows too. Command Prompt does not treat the single quote as a quote character. The copied command sends the quote marks as part of the JSON body, the parser rejects it, and you get ERROR_ADMIN_BAD_REQUEST on a command that looks perfectly correct.
| Shell | Empty body | With fields |
|---|---|---|
| Command Prompt | -d "{}" | -d "{\"name\":\"prod\"}" |
| PowerShell, using curl.exe | -d '{}' | -d '{\"name\":\"prod\"}' |
| Git Bash, macOS, Linux | -d '{}' | -d '{"name":"prod"}' |
In PowerShell, call curl.exe by its full name. Plain curl there is an alias for Invoke-WebRequest, which takes different arguments entirely and will fail in a way that has nothing to do with the API.
Error codes
| Status | Code | Cause |
|---|---|---|
| 404 | plain text | Feature off, or unknown path. Deliberately indistinguishable. |
| 401 | ERROR_ADMIN_UNAUTHORIZED | Token missing, malformed, or wrong. |
| 405 | ERROR_ADMIN_METHOD_NOT_ALLOWED | You sent something other than a POST. |
| 400 | ERROR_ADMIN_BAD_REQUEST | Malformed body: add with no name, delete with both selectors or neither, an unknown bucket name, a credits or grants value that is not a bare non-negative integer, or an add-mode grant without a credits value above zero. Nothing is created or changed. |
| 409 | ERROR_KEY_EXISTS | That name or value is already in use. On a subscribe handler this is a replayed webhook. |
| 409 | ERROR_KEY_NAME_AMBIGUOUS | A delete or grant by name matched several keys. Select by key value instead. |
| 404 | ERROR_KEY_NOT_FOUND | A delete or grant matched nothing. Usually an already-processed cancellation. |
| 500 | ERROR_ADMIN_STORE_FAILURE | Could not save. Usually File mode with no file selected. |
One more code belongs here even though it comes from the other side of the product. ERROR_ZERO_BALANCE is returned by the solving endpoints rather than this admin API, when a key has run out of credit in the bucket the task belongs to. If a customer reports it, the answer is a top-up rather than anything wrong with provisioning.
These sit alongside the solving error codes rather than replacing them, and the full set is in the CapSkip API documentation.
FAQ
How fast does a new key actually start working?
On the next solving request. Nothing is cached and nothing needs reloading, so a customer who receives their key from your checkout page can use it in the same minute. Revocation is just as immediate in the other direction, which is why the timing of your cancellation handler is a policy decision rather than a technical one.
Does each customer key cost me anything?
Still nothing. Minting a key is free and there is no vendor meter behind it. What changed is that a key can now carry your own credit limits, so it can represent a plan tier: the accounting is yours, and so is the pricing. The CAPTCHA solving SDK reads whichever key you hand it, so how you divide them up is entirely your design.
Can I rate limit a customer through this API?
No, and credit limits are not a substitute for one. A quota is a budget, not a requests-per-second ceiling: it caps how much a customer gets in total, not how hard they can hit the endpoint in any given second. Rate limiting still belongs at the reverse proxy you are already putting in front of the instance, keyed on the API key the customer sends.
Do credit limits reset every month?
No. A grant is a balance that counts down until you change it, and nothing inside CapSkip runs on a calendar. Call the grant endpoint in set mode at the start of each billing cycle to reset it to the plan allowance, or in add mode if unused credits should roll over instead.
Do failed solves cost the customer credits?
No. Credit is spent only on a successful solve. Failures are counted separately in the usage report so you can still see them, and a task that is in flight when it fails hands its reservation back. That means a customer being challenged hard by a difficult site does not quietly burn through their allowance on attempts that returned nothing.
What happens to my existing keys when I upgrade CapSkip?
Nothing. Every key you have already issued is unlimited in all eight buckets until you set a limit on it, and add calls that say nothing about grants keep creating unlimited keys exactly as before. The feature is inert until an operator opts into it, so there is no migration step and no window where customers lose access.
Can customers see their own remaining credits?
Through a balance check, yes. Once limits are set it reports their real remaining total rather than the old sentinel value, so the number counts down as they work. The per-bucket breakdown is a different matter: it needs the admin token and returns every key at once, so surface it from your own dashboard rather than exposing that endpoint to customers.
I lost the admin token. What now?
Generate a new one in the same settings panel. The old token stops working immediately, so there is no cleanup step and no window where both are valid. Your customers are unaffected: their keys are untouched and they keep solving throughout. The only thing that breaks is your own provisioning until you update the secret your webhook handler reads.
The shortest version
Turn on Remote Key Management once and keep the token with your other billing secrets. Add a key named after the customer when their subscription goes active, with the plan’s grants already on it, and treat the 409 on a replay as success. Reset or top up those limits from your renewal webhook, since nothing here runs on a calendar. Poll the usage endpoint on a schedule for both billing reconciliation and dormant keys. Delete by name when the subscription ends, and diff the key list against your active subscribers, because that is the only place the real answer lives. Then put a proxy in front and keep the admin path off the port your customers can see. Do that and captcha bypass becomes something you can resell on your own hardware, metered by your own numbers and provisioned as fast as your checkout can fire a webhook.
