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 both endpoints from the browser. You usually
don't need to call either from your backend — you just verify the captcha_attestation
the widget produces. 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":"sk_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 request 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 | 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
Validates a submitted solution against an existing token and, on success, returns a signed attestation your backend can verify locally. Tokens are invalidated after the first call, pass or fail.
Request
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | The token returned by /challenge. |
solution | string | yes | The nonce found by the client (a decimal integer serialised as a string). |
curl -X POST https://captchaapi.eu/api/v1/captcha/verify \
-H 'Content-Type: application/json' \
-d '{"token":"a1b2c3...","solution":"47821"}'
Response (200 OK)
{
"success": true,
"attestation": "eyJzayI6InNr...Q.6ab9c4...",
"attestation_expires_at": 1765457089,
"error_code": null,
"over_limit": false
}
| Field | Type | Description |
|---|---|---|
success | boolean | true iff the solution satisfies the target and the project is still valid. |
attestation | string|null | Signed proof on success, null otherwise. See Attestation format. |
attestation_expires_at | integer|null | Unix timestamp after which the attestation is stale. Convenience field — also encoded inside the payload as exp. |
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 token 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. Mirrored in the payload as ol. |
Note: most verify failures still return HTTP 200 — check success,
not the status code. Only rate-limiting returns a 4xx status.
Errors
| Status | error_code | Meaning |
|---|---|---|
| 200 | invalid_token | Token missing, expired, already used, or never existed. Also returned if the project was deleted between challenge and verify. |
| 200 | ip_mismatch | Verify call came from a different IP than the one that received the challenge. The token is discarded to prevent replay. |
| 200 | invalid_solution | Hash of token + solution didn't clear the target. |
| 429 | rate_limited | More than 200 verify requests from one IP in 60 seconds. |
Attestation format
The attestation returned by /verify is a string of two base64url-encoded
segments separated by a dot:
base64url(payload_json) . base64url(hmac_sha256(payload_b64, secret_key))
The payload is a compact JSON object with these fields:
| Field | Type | Description |
|---|---|---|
sk | string | Your site key. Verify it matches your project. |
iat | int | Issued-at unix timestamp. |
exp | int | Expiry unix timestamp. Reject attestations past this time. Configured per project (1 min – 10 min, default 5 min) in the dashboard. |
jti | string (uuid) | Unique identifier — always present. Cache it with an atomic SET NX for the remaining exp - now() seconds to reject replays (pattern); local HMAC verification means there's no central state for us to dedup against, by design. |
ol | bool | Over-limit flag, matching the top-level over_limit. |
Encoding details
- Algorithm: HMAC-SHA256.
- Key: the raw secret key string as shown in your project dashboard.
- Message: the base64url payload string (not the decoded JSON).
- Encoding: base64url (
-_alphabet) with padding stripped. Standard base64 (+/with=padding) will not verify — you must translate first.
Verification steps
- Split the attestation at
.— two parts. - Recompute
hmac_sha256(payload_b64, secret_key)and compare to the second part using a constant-time equality check. - If equal, base64url-decode the payload and parse as JSON.
- Check
exp >= now()andsk == your_site_key. - Recommended: atomically claim
jtiin a cache (e.g. RedisSET NX) with TTLexp - now()— see backend examples.
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 | Roughly 2× the challenge ceiling so the verify side never bottlenecks a healthy challenge stream. |
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. |
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 | Token expired or was already used. Request a fresh challenge. |
ip_mismatch | verify | Usually means a proxy/CDN in front of the widget is changing the client IP between challenge and verify. Make sure both calls use the same edge. |
invalid_solution | verify | The widget should prevent this. If you see it, either someone is submitting without solving or the form was tampered with. |
internal_server_error | challenge | Retry with backoff. |
Need copy-paste code? See Backend examples.