API reference
captchaapi.eu exposes two JSON endpoints. Both accept Content-Type: application/json
and return JSON. The base URL is https://captchaapi.eu/api/v1.
In a standard integration the widget calls /challenge from the browser, and
your backend calls /verify server-to-server with the
captchaapi_response form field. The widget never calls /verify.
See Backend examples.
POST /api/v1/captcha/challenge
Issues a new PoW challenge bound to the caller's IP. The returned token
lives for 2 minutes and can be redeemed exactly once.
Request
| Field | Type | Required | Description |
|---|---|---|---|
site_key | string | yes | Your project's public site key. |
Origin validation: if your project has Allowed Domains configured, the request
must carry an Origin or Referer header that matches one of them.
Projects with no configured domains accept any origin.
curl -X POST https://captchaapi.eu/api/v1/captcha/challenge \
-H 'Content-Type: application/json' \
-H 'Origin: https://your-site.com' \
-d '{"site_key":"pk_live_abc123..."}'
Response (200 OK)
{
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"target": 1048575,
"expires_at": 1765456789
}
| Field | Type | Description |
|---|---|---|
token | string (32) | Opaque challenge identifier. Echo back on verify. |
target | integer (uint32) | Solve condition: first 8 hex chars of sha256(token + solution) must be ≤ target. |
expires_at | integer (unix ts) | Token invalid after this time. Always 2 minutes from issue. |
Errors
| Status | error_code | Meaning |
|---|---|---|
| 422 | invalid_site_key | The site_key field is missing or doesn't match any project. |
| 402 | account_suspended | The project's owning account is suspended due to a billing failure (paid tier, dunning exhausted). Returned in preference to project_inactive so the integrator can route the visitor to a billing-reactivation flow rather than a generic "project disabled" page. |
| 403 | free_tier_limit_reached | The project's owner is on the Free tier and has exhausted the monthly challenge quota. The account is in the Free-tier hard-cap state and all of its projects are paused until the owner upgrades or the next billing cycle resets. Returned in preference to project_inactive so the integrator can surface an upgrade CTA. |
| 403 | account_deactivated | The project's Free-tier owner has hit the monthly cap repeatedly and the account is now locked. Unlike free_tier_limit_reached, this does not clear on the next billing cycle - the owner must upgrade to restore service. |
| 403 | project_inactive | The project exists but is disabled in the dashboard (manual disable; no billing implication). |
| 403 | domain_not_allowed | The request's Origin/Referer isn't in the project's allowed domains. |
| 429 | rate_limited | Per-IP or per-project rate limit exceeded - see Rate limits. Response includes retry_after (seconds). |
| 500 | internal_server_error | Unexpected server-side failure. Logged; safe to retry with backoff. |
Error response shape:
{"success": false, "error_code": "rate_limited", "retry_after": 42}
POST /api/v1/captcha/verify
Called from your backend, server-to-server, with the captchaapi_response form
field. Authenticates with your project's secret key in an Authorization: Bearer
header. A given response verifies exactly once - single-use and replay protection are
enforced here, on our side.
Request
Send your secret key (format sk_live_..., kept only on the server) as a Bearer
token. The body carries the value of the captchaapi_response form field:
| Header / Field | Type | Required | Description |
|---|---|---|---|
Authorization | header | yes | Bearer <your secret key>. Never expose this in the browser. |
response | string | yes | The value of the captchaapi_response hidden input the widget added to the form. |
curl -X POST https://captchaapi.eu/api/v1/captcha/verify \
-H 'Authorization: Bearer sk_live_...' \
-H 'Content-Type: application/json' \
-d '{"response":"a1b2c3....47821"}'
Response (200 OK)
{
"success": true,
"error_code": null,
"over_limit": false,
"risk": {
"score": 0,
"signals": { "headless": false }
},
"network": { "datacenter": false, "tor": false, "abuse": false }
}
| Field | Type | Description |
|---|---|---|
success | boolean | true iff the response is valid and its solution satisfies the target. Allow the form through only on true. |
error_code | string|null | null on success, otherwise one of the codes below. |
over_limit | boolean | true if the project is past its monthly quota. The challenge was issued on the same baseline PoW curve as in-quota traffic (no soft-serve shortcut to a trivial target) and verification still succeeds - treat it as a signal to upgrade, not a hard failure. |
risk | object | Advisory. A score (0-100) and per-category flags from our headless / automation signals, e.g. signals.headless. Present when the response matched a challenge token (so on success and invalid_solution, not on invalid_token / invalid_secret). Optional to use - success already reflects the proof-of-work. Use it for extra friction or logging on suspicious traffic, never as the only gate. The signals are soft and forgeable by design. |
network | object | Advisory connection facts, separate from risk and never folded into its score. Each enabled source is a boolean - datacenter (the visitor's IP belongs to a hosting / datacenter network), tor (a known Tor exit node), and abuse (the IP is on a public abuse blocklist - confirmed attack / spam / compromised-host traffic, not a VPN detector). Plenty of real visitors browse via VPNs, Tor and cloud egress, so threshold these against your own threat model rather than reading them as our verdict. Present under the same conditions as risk. false means "checked, clean", which is distinct from a source being absent (not evaluated). |
Note: a normal pass or fail returns HTTP 200 - check success,
not the status code. As with every CAPTCHA provider, HTTP 200 does not mean pass. Only a
bad or missing secret (401), rate-limiting (429), and unexpected server errors (500) return
a non-200 status.
Errors
| Status | error_code | Meaning |
|---|---|---|
| 200 | invalid_token | The response is unknown, expired, or already used. Also returned if the project was deleted between challenge and verify. |
| 200 | invalid_solution | The proof-of-work in the response didn't satisfy the target. |
| 401 | invalid_secret | The Authorization: Bearer secret is missing, malformed, or doesn't match the project. |
| 429 | rate_limited | More than 200 verify requests from one IP in 60 seconds. |
| 500 | internal_server_error | Unexpected server-side failure. Logged; safe to retry with backoff. |
Rate limits
All limits are tracked per hashed IP (sha256(ip + secret)) over a rolling
60-second window unless stated otherwise. When a limit is tripped, the response is HTTP 429 with
{"success": false, "error_code": "rate_limited", "retry_after": N} - the
retry_after value (seconds) is what the widget surfaces to the visitor as a live
countdown.
CAPTCHA API
| Endpoint | Limit | Window | Notes |
|---|---|---|---|
POST /api/v1/captcha/challenge |
100 / IP | 60 s | Standard limit for accounts within their monthly quota. |
POST /api/v1/captcha/challenge (over-limit accounts) |
500 / IP | 60 s | Once the project's monthly quota is exhausted, paid plans keep serving on the same baseline PoW curve as in-quota traffic - visitors are never blocked. The per-IP cap is raised here so legitimate bursts continue to be served while still bounding abuse. |
POST /api/v1/captcha/verify |
200 / IP | 60 s | Keyed on your backend's egress IP (verify is a server-to-server call). Sits well above a healthy submission stream; if a single server fronts very high form volume, contact me to raise it. |
Within the monthly quota, a per-project aggregate cap bounds challenge traffic at 2 000 requests per project per 60-second window, combined across every IP. It catches a distributed flood that keeps each individual IP under the 100 / IP cap above, and sits far above any legitimate per-minute volume - a project within its quota does not meet it under normal use.
A separate per-project daily ceiling bounds over-limit traffic at 2 000 000 requests per project per day, anchored to absorb a 100× viral surge for the largest plan with headroom. This is an anti-DoS bound for over-limit traffic only - it does not affect accounts within their monthly quota.
Authentication & signup forms
| Surface | Limit | Window | Key |
|---|---|---|---|
Sign-in (POST /login) |
5 | 60 s | Per (email + IP). Standard Fortify throttling. |
Two-factor challenge (POST /two-factor-challenge) |
5 | 60 s | Per session. |
All thresholds above are configured via environment variables and may be tuned with reasonable
notice in line with the
Terms of Service Section 11
change-policy. If you see rate_limited regularly from legitimate traffic, you are
likely sharing an egress IP - get in touch.
Error code glossary
error_code | Endpoint | What to do |
|---|---|---|
invalid_site_key | challenge | Check you're passing the right key for the environment (staging vs. production are separate projects). |
account_suspended | challenge | The project's owner has an unpaid invoice past the grace window. Surface a "billing issue - please contact site admin" page to your visitors and get the account holder to settle billing in the dashboard. |
free_tier_limit_reached | challenge | The project's Free-tier owner has used all monthly challenges. Surface an upgrade CTA, or wait for the next billing cycle. Distinct from project_inactive so you can show a quota-exhausted message rather than a generic disabled page. |
account_deactivated | challenge | The project's Free-tier owner has been locked out after repeated cap hits. Waiting for the next cycle won't help; surface an upgrade CTA and get the account holder to upgrade in the dashboard. |
project_inactive | challenge | Re-enable the project in the dashboard. |
domain_not_allowed | challenge | Add the domain to the project's allowed list. Include the port for non-standard ports (e.g. localhost:3000). |
rate_limited | both | Wait retry_after seconds. If you see this regularly from legitimate traffic, you're likely sharing an egress IP - contact me. |
invalid_token | verify | The response is unknown, expired, or already used. The visitor must solve a fresh challenge. |
invalid_solution | verify | The widget should prevent this. If you see it, either someone is submitting without solving or the form was tampered with. |
invalid_secret | verify | Check the secret key in your server config. It must be the project's sk_live_... key, sent as Authorization: Bearer. |
internal_server_error | both | Retry with backoff. |
Need copy-paste code? See Backend examples.