Browser API
The otcaptcha.init call, its configuration, the widget it returns, every event and payload, and what the loader does on the network.
The global
The script defines window.otcaptcha the moment it runs, as a frozen object with two members. It is defined synchronously, so a script that comes after the loader can use it at once; a script that comes before it must wait for the load event of the loader's tag. If a page already has an otcaptcha.init function when the loader runs, for example because the file was included twice, the first one is left in place.
otcaptcha.init(config, callback); // synchronous: callback(widget) runs before init returns
otcaptcha.version; // the loader version, for example "1.2.1"On execution the script also adds a <link rel="preconnect"> for the API host to the document head and starts counting pointer moves, clicks and similar page events, which later feed the risk score. It makes no network request until init is called, and it never sets a cookie or reads one. Loaded inside a Web Worker, the same file becomes the solver instead; the loader does this itself and you never call it that way.
Configuration
otcaptcha.init(
{
captchaId: '[SITE KEY]', // required
language: 'pt-BR', // optional; the region part is dropped
apiServers: ['https://captcha.example.com/captcha/v1'], // optional; self-hosted service only
},
(widget) => { /* attach handlers, then widget.showBox() */ }
);| Key | Type | Meaning |
|---|---|---|
captchaId | string, required | Your site key: 32 lowercase hex characters. It is public. An unknown key makes onError fire with code 60100. |
language | string, optional | The language of the box. en, es, pt, ar, hi and zh are available; anything after a hyphen is dropped, so pt-BR is Portuguese, and case does not matter. The three-letter forms eng, spa and ara are accepted too. Anything else, or no value, is English. Arabic lays the box out right to left. |
apiServers | string array, optional | The first entry replaces the API base the loader was built with. Only for a self-hosted service; a site using our service leaves it out. |
Other keys are ignored. The configuration object is not read again after init returns.
The widget
The callback receives one widget. It is one attempt: it fetches one challenge when it is created, runs one check when showBox() is called, and holds at most one pass. For a second attempt, destroy it and call init again. Creating a widget is cheap.
| Method | Returns | What it does |
|---|---|---|
onReady(fn) | the widget | Calls fn() when the challenge has arrived, or when the service could not be reached and the widget will work in offline mode. It does not fire after a refused site key. |
onSuccess(fn) | the widget | Calls fn() when a pass exists. From then on getValidate() returns it. |
onFail(fn) | the widget | Calls fn(payload) each time the service refuses an attempt: a hold it would not accept, a puzzle piece out of place, or a verification that did not check out. The visitor may still pass afterwards. |
onClose(fn) | the widget | Calls fn() when the visitor closes the box with the close button or Escape. No pass will come from this widget. |
onError(fn) | the widget | Calls fn(payload) when the attempt cannot continue. No pass will come from this widget. |
getValidate() | pass or false | false until a pass exists, then the pass object below. It stays available until reset(). |
showBox() | nothing | Runs the check. Most attempts pass with no box; the others open a hold or a puzzle. A second call on the same widget does nothing. |
showCaptcha() | nothing | The same as showBox(). |
reset() | nothing | Forgets the pass, so getValidate() is false again. It does not run the check again. |
appendTo() | the widget | Accepted for pages that call it; it has no effect. The box is always a dialog on top of the page. |
destroy() | nothing | Stops the background workers, removes any open box, drops every handler and fires no further events. Call it when you are done with the widget. |
Every on… method returns the widget, so calls chain. Events are remembered: a handler attached after its event has already fired is called anyway, asynchronously, with the same payload. Handlers attached after destroy() are never called.
What fires when
The table reads top to bottom for each case. showBox() is where the check runs; before it, only ready or error can fire.
| Case | Events, in order | What the visitor sees |
|---|---|---|
| Normal attempt | ready, then after showBox(): success | Nothing. The work ran in the background. |
| Suspicious attempt | ready, success once the hold is complete. A hold released too early is asked again inside the box with no event; a hold the service refuses fires fail and the box asks for another. | A press-and-hold box. |
| Attempt that looks automated | ready, one fail per missed slide, success when the piece fits. After the allowed misses, or on "Use a different check", the box becomes a hold; success comes from that. | A slide puzzle, with a switch to the hold. |
| Visitor closes the box | ready, close | The box disappears. Your page decides what happens next. |
| Site key refused | error with code 60100. No ready. | Nothing. |
| Verification refused | ready, fail, error with code 60500. The challenge was older than ten minutes, or the answer did not check out. | Nothing. |
| Service unreachable | ready, then after showBox(): success with an offline pass. | Nothing. See Outage policy: your server refuses the offline pass if it can reach us, and fails open or closed if it cannot. |
An attempt that fails before the box opens cannot be retried on the same widget. Destroy it and call init again.
Payloads
The pass
getValidate() returns an object with five string members. None is ever empty. Send all five to your server unchanged; the server check needs each of them.
{
"captcha_id": "[SITE KEY]",
"lot_number": "3f9c2b6e4d1a4f0b9c8e7d6a5b4c3d2e", // 32 hex characters
"captcha_output": "eyJ0Ijoibm9ybWFsIiwicyI6MH0", // opaque, base64url
"gen_time": "1793955600", // unix seconds, as a string
"pass_token": "9a1f…(64 hex characters)"
}An offline pass has the same five members, with a lot_number that starts with offline and a captcha_output of offline. The verify endpoint refuses it whenever it is reachable.
Errors and failures
// onError payload
{ "code": "60100", "msg": "captcha id refused", "desc": { "detail": "captcha id refused" } }
// onFail payload
{ "captcha_id": "[SITE KEY]", "captcha_type": "otc_pow", "challenge": "", "lot_number": "3f9c…" }| Code | Meaning | What to do |
|---|---|---|
60100 | The site key was refused when the challenge was requested. | Check the key. It is the same value you use on the server. |
60500 | The service refused the verification: an expired challenge, an answer that did not check out, or a key it no longer knows. | Destroy the widget and call init again. If it repeats, see Troubleshooting. |
The onFail payload identifies the attempt. captcha_type is always otc_pow and challenge is always empty; lot_number is the attempt's number, the same value your server will see if the visitor passes later.
Timing
- Each call to the service has a 2.5 second timeout and is tried twice, 300 ms apart. So
readyorerrorfires within about 5.3 seconds ofiniteven when the service cannot be reached, andshowBox()reachessuccessor a box within the same bound. - The background work for a normal attempt is 64 small puzzles of 12 bits each, solved in parallel. During an attack it is raised: see How a check works.
- A challenge is valid for ten minutes from
init. A pass is valid for five minutes fromsuccess. Confirm it on your server as soon as it arrives, and do not store it for later. - A hold lasts 1.5 seconds. The box times it, and the service also checks that at least that long passed since it issued the hold.
What the loader does on the network
All browser calls go to https://api.otcaptcha.com/captcha/v1, or to the first apiServers entry. Every request is sent without cookies or credentials, and no request carries anything typed on your page.
| Request | When | Carries |
|---|---|---|
GET /load | On init | Query: the site key, client_type (h5 on a touch device, else web) and the loader version. Returns a signed challenge and, when one is set for the site, the brand look. |
POST /verify | On showBox(), and once per hold or slide | JSON as a text/plain body, so there is no preflight: the challenge as issued, the solution, the browser signals below, and for a hold the time held, for a slide the end position and the drag trace. |
POST /puzzle | When a puzzle is shown | The signed puzzle challenge. Returns the picture and the piece, which are drawn on a canvas, so no image URL is loaded. |
POST /funnel | When a hold or puzzle box ends | One sendBeacon with the box's challenge and four counts: holds or drags started, released early, finished and refused, plus how the box ended. Nothing waits for it. See Reports and data. |
The browser signals
Sent with each verify call and used for the risk score: whether the browser declares itself automated, the names of known automation tools found on the window, the user agent and platform strings, the number of cores, plugins and languages, screen and window size, time zone, page language, touch support, how long the page has been open and watched, how many pointer moves and clicks happened and how straight the longest pointer path was, how long the solve took and whether it ran in workers, in WebAssembly or in plain JavaScript. The full list is the signals object in the verify request, visible in your browser's network panel.
Workers and WebAssembly
// The widget solves in parallel: up to four workers, or fewer on a device with fewer cores.
// Nothing to configure. If workers cannot start, the main thread solves in 8 ms slices.The workers are started from the loader's own URL, so the origin that serves the script must be allowed by the page's worker-src (or script-src when there is no worker-src). The solver is WebAssembly embedded in the file; where the page's policy or the browser forbids WebAssembly, the same solve runs in JavaScript, more slowly. Both fallbacks are automatic. See Content security policy.
The box
When a box is needed it is a dialog appended to document.body, above everything else on the page, with its own stylesheet inside it. Class names start with otc_. It needs nothing from your CSS and does not depend on the layout of your page. On screens up to 480 px wide it rises as a sheet from the bottom of the screen. Tab stays inside it, Escape closes it, focus returns to where it was when it ends, and animations and vibration are skipped when the device asks for reduced motion. Colours, corner radius and the trust line are set per site: see Brand look.