Troubleshooting
Updated
This page lists every reason that the verify API gives when the answer is not {"ok": true}. For each reason, it gives the cause and the fix. The strings on this page are the exact strings that the API sends.
On the page of each site, the dashboard shows the failures by reason. It also shows the latest events and their origins. The same counts are in totals.fail of GET /api/v1/stats.
| Reason | HTTP | Most common cause |
|---|---|---|
invalid | 200 | The form arrived without a token. |
expired | 200 | The token was checked after it expired. The site settings set the expiry. |
replay | 200 | The same token was checked two times. |
wrong_site | 200 | The page and the server use keys of different sites. |
honeypot | 200 | The hidden honeypot field had a value. |
automation | 200 | Playwright, Puppeteer or Selenium controlled the browser. |
quota | 402 | The month is capped, and the site used more checks than the plan limit. |
unauthorized | 401 | The secret key is wrong or missing. |
rate_limited | 429 | The site got too many verify calls in one minute. |
too_large | 413 | The request body was larger than 64 KB. |
| origin not allowed | 403 on the challenge | The host of the page is not on the list of allowed origins for the site. |
invalid
{"ok": false, "site": "pk_live_…", "reason": "invalid"}
The token is missing or damaged, or the answer to the puzzle is wrong. A bot that posts the form and does not run the script gets this answer. This is correct. If your own test gets this answer, usually the token did not get to the verify call. Examine these points in this order:
- Is there a token in the form? Click in a field of the form. Then run
document.querySelector('input[name="_bot403"]').valuein the browser console. If the result is empty ornull, the script did not run or did not get a challenge. - Does the script run? The script needs its key:
data-bot403-keyon the script tag, or on the form. The script must also load as a classic script with its own URL. A bundler that inlines the script prevents the classic load, and so does an Astro<script>withoutis:inline. In these cases, the script does nothing. - Does the challenge load? In the network tab, find the request to
/api/v1/challenge. A 403 means origin not allowed. A 404 with"unknown public key"means that the public key is wrong. If the browser blocked the request, the cause is often a Content Security Policy. In this case, allowhttps://bot403.cominscript-srcandconnect-src(see script options). - Does your server send the field? Send the form as
application/x-www-form-urlencoded, or as JSON with"solution"or"_bot403". The verify API does not read amultipart/form-databody. Thus every token in such a body fails withinvalid. In JavaScript, this happens if you pass aFormDataobject directly tofetch.
If the body is not valid JSON, the answer is HTTP 400 with {"ok": false, "error": "invalid", "reason": "invalid", "message": "body is not valid JSON"}. The maximum size of a token is 4 KB.
expired
{"ok": false, "site": "pk_live_…", "reason": "expired"}
The challenge is older than the Challenge expiry, seconds setting of the site. The default is 600 seconds, and the range is 60 to 3600. The script itself never sends an old token. If its challenge is about to expire when the visitor sends the form, the script gets a new challenge. Thus expired means that something kept the token too long before the verify call:
- Your server checks the token later, for example in a queue or a background job. Check it in the request that receives the form.
- Your fetch code calls
window.Bot403.solve()one time and keeps the token. Call this function for each request. - The visitor opened the page and got a token. Then the visitor sent the form much later. If visitors need a long time to complete your forms, increase the expiry in the settings of the site.
replay
{"ok": false, "site": "pk_live_…", "reason": "replay", "origin": "shop.example.com", "difficulty": "medium"}
Each token passes the check one time only. This reason means that the token was already used. The common causes are:
- Your code calls the verify API two times for one submission. For example, it calls the verify API in a middleware and again in the handler, or it retries after a timeout. Call the verify API one time and keep the result.
- The visitor reloaded the page that the POST returned. Then the browser sent the same form again. In this case, the refusal is correct. A redirect after the POST (as in the guides) prevents most of these cases.
- Your fetch code reuses a token from
solve(). Get a new token for each request.
A replayed token is not counted in the quota.
wrong_site
{"ok": false, "site": "pk_live_…", "reason": "wrong_site"}
The token belongs to a different site than the secret key. The page uses the public key of one site. The server uses the secret key of a different site. This mistake is common if you have separate sites for staging and production. The site field in the answer is the site of the secret key. Compare it with data-bot403-key on the page.
honeypot
{"ok": false, "site": "pk_live_…", "reason": "honeypot"}
The script adds a hidden text field to each protected form. The name of this field is _website, unless you changed it in the Honeypot field name setting of the site. People never see this field. Thus a value in it means that a bot filled every field. Refuse the submission, or send a fake success answer. With a fake success, the bot learns nothing.
If real visitors get this answer, find out whether your form already has a field with the honeypot name. If it has such a field, the script uses your field as the honeypot. Then each value in this field fails. In this case, set a different honeypot name in the settings.
automation
{"ok": false, "site": "pk_live_…", "reason": "automation", "automation": {"verdict": "automated", "score": 0, "reasons": ["webdriver"], "mode": "enforce"}}
This reason comes only if automation checks run in enforce mode and the Automated browsers setting of the site is Balanced or Strict. The reasons list shows what was found, for example webdriver or headless_ua. In log mode, the verdict is only reported, and ok does not change.
Your own end-to-end tests with Playwright, Puppeteer or Selenium get this answer, and this is intentional. For tests, use a separate site with Automated browsers set to Off. On Strict, a real visitor can be refused in rare cases. An example is a visitor whose password manager submits the form. Balanced refuses only signals that the browser of a real person cannot give.
quota (HTTP 402)
{"ok": false, "site": "pk_live_…", "reason": "quota", "origin": "shop.example.com", "difficulty": "medium"}
In a normal month, the verify API never gives this reason. It does not matter how far over the plan you go. A month is capped only after two calendar months over the plan in a row. In a capped month, if you go over the limit, the verify API answers 402 with quota. Bot403 emails you at 80 %, 100 % and 150 % of the limit. It also emails you before a capped month starts.
The token stays usable: after an upgrade in Settings, the same token passes the check. The examples in the guides accept the submission on quota. Thus a capped month does not lose real messages. If you want to refuse the submission, change that line in your code. For the limits, see pricing.
unauthorized (HTTP 401)
{"ok": false, "error": "unauthorized", "reason": "unauthorized", "message": "invalid or missing secret key"}
- The environment variable is empty on the server. Print whether
BOT403_SECRET_KEYis set. Never print its value. In Laravel, afterphp artisan config:cache,env()outside the config files returnsnull. - The key is the public key (
pk_). The server needs the secret key (sk_). - The key was rotated. The old key stops working immediately. Copy the new key from the settings of the site.
- The header is wrong. The correct header is
Authorization: Bearer sk_live_…, with one space.
rate_limited (HTTP 429)
{"ok": false, "error": "rate_limited", "reason": "rate_limited", "message": "too many verify requests"}
Each site has a limit for verify calls: 100 a minute on Free, 1,000 on Starter and 3,000 on Pro. The answer has a Retry-After header. The usual cause is a loop in your code or a load test. The challenge endpoint has its own limits, per IP address and per site. Over these limits, it answers 429 with "too many challenge requests".
too_large (HTTP 413)
{"ok": false, "error": "too_large", "reason": "too_large", "message": "request body is larger than 64 KB"}
The body of a verify call is 64 KB at most. If you post the full form and it has long text, the body can be larger than this. In this case, send only the token: JSON {"solution": "…"}. But then you lose the honeypot check, unless you also send the honeypot field.
Origin not allowed
{"ok": false, "error": "origin", "message": "origin not allowed for this site"}
This answer comes from the challenge endpoint, in the browser, with HTTP 403. The host of the page is not on the Allowed origins list of the site. The browser console shows this as a CORS error:
Access to fetch at 'https://bot403.com/api/v1/challenge?pk=…' from origin 'https://www.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
The form still submits, but without a token, and your verify call answers invalid. Add the host to the list, one host on each line. An entry without a port matches that host on all ports. *.example.com allows the subdomains, but not example.com itself. If you use both, add both to the list. An empty list allows every origin.
When Bot403 does not answer
If the challenge request fails, the script lets the form submit without a token. Then the verify API answers invalid. If the verify call itself fails with a network error or a timeout, your code makes the decision. The code in the guides accepts the form and logs the error. Thus an outage never loses a message. Look at the status page.
If the problem continues, email [email protected]. Give the public key of the site and the time of a failed attempt. I usually answer email within 1-2 business days.