Turnstile callback not firing
If your Turnstile callback is not firing, the widget almost always never rendered or never solved — or the function you named exists, but not in the global scope where Turnstile looks it up. Add an error-callback before you debug anything else: Cloudflare's docs state that without one, Turnstile throws a JavaScript exception on error instead of telling you what failed.
First, decide whether the widget failed or the wiring failed
These are two different bugs with two different fixes. Separate them before you change any code.
Ask the widget directly. With explicit rendering, call turnstile.getResponse(widgetId) from the console after the widget looks solved. With implicit rendering inside a <form>, read the hidden input Turnstile creates: document.querySelector('[name="cf-turnstile-response"]').value.
- A token comes back, but your callback never ran — the challenge is fine. Your callback is misnamed, out of scope, or attached to the wrong widget. Jump to the naming and scope section.
- Nothing comes back — the challenge never completed, or the widget never rendered at all. Check whether the widget's iframe exists in the DOM under your container.
- The container is empty — the widget was never created. This is an implicit-versus-explicit rendering problem.
Then attach an error-callback and log the code. Error codes carry their meaning in the first three digits: 110200 is a domain not listed in Hostname Management, 110100 and 110110 are sitekey problems, 200500 is the Turnstile iframe failing to load, and 300*/600* are challenge failures. A returned truthy value tells Turnstile you handled it; a falsy return makes Turnstile log the code to the console, which is what you want while debugging.
Swap your sitekey for the test key 1x00000000000000000000AA, which always passes. If your callback fires with the dummy token XXXX.DUMMY.TOKEN.XXXX, your wiring is correct and the problem is the real widget's configuration or the challenge itself. If it still does not fire, the bug is in your code. 3x00000000000000000000FF forces an interactive challenge, which is the fastest way to reproduce hidden-container and timeout problems.
Implicit rendering only sees containers that exist when it scans
Implicit rendering — plain api.js with no query string — scans your HTML for elements carrying the cf-turnstile class and renders widgets on page load. That scan is not a live observer of your DOM. If your form is injected by JavaScript after the page loads, opened in a modal, or rendered by a framework on a route change, implicit rendering never sees the container. No widget is created, so no callback can ever fire. Cloudflare's own guidance is to use explicit rendering for dynamic content and single-page applications where forms are created after the initial page load.
Three variants of this produce identical silence:
- The container is there but has no
cf-turnstileclass — nothing to scan for. - You loaded
api.js?render=explicitand left acf-turnstilediv in the page. Therender=explicitparameter disables the automatic scan. You must callturnstile.render()yourself. - You call
turnstile.render()beforeapi.jshas executed. Wrap the call inturnstile.ready(() => { ... }), or load the script with?render=explicit&onload=yourFunctionand render from inside that function.
Turnstile also refuses to run on pages served over file://. Only http:// and https:// are supported, so opening your test HTML straight from disk gives you a widget that never initialises and a callback that never fires. Serve it over localhost instead — the test sitekeys work on any domain, including localhost and 127.0.0.1.
data-callback takes a name; callback takes a function
This is where most working widgets go quiet. The two rendering paths take the same option under two different types, and they are not interchangeable.
With implicit rendering, data-callback="onSuccess" is a **string**. Turnstile resolves that name in the global scope. In Cloudflare's examples the function is declared in a plain <script> block, which puts it on window. The moment you move that code into <script type="module">, a bundled entry point, or any module-scoped file, the declaration is no longer global and the lookup finds nothing. The widget still solves. Your function is never reached.
// Broken inside a module or a bundle: function declarations are module-scoped. function onSuccess(token) { document.getElementById("submit").disabled = false; } // Works: the name Turnstile looks up now exists on window. window.onSuccess = function (token) { document.getElementById("submit").disabled = false; };
The same applies to the onload query parameter on api.js. ?onload=onTurnstileLoad is a global name, not a reference you can pass across module boundaries.
With explicit rendering you pass the function itself, so scope is not an issue — but the key names are. The hyphenated options are literal object keys and must be quoted. errorCallback, onError, expiredCallback and onExpired are not option names; Turnstile ignores unknown keys without complaint, which is why a mistyped key looks exactly like a callback that will not fire. The exact set is callback, error-callback, expired-callback, timeout-callback, before-interactive-callback, after-interactive-callback and unsupported-callback. Only callback is unhyphenated.
Causes and fixes
| Cause | What you see | Fix |
|---|---|---|
Container injected after api.js scanned the page | Container is empty, no iframe | Load api.js?render=explicit and call turnstile.render() after you insert the container |
Container has no cf-turnstile class (implicit rendering) | Container is empty | Add the class, or switch to explicit rendering |
Loaded ?render=explicit but never called turnstile.render() | Container is empty | Call turnstile.render(), or drop the render=explicit parameter |
Callback declared in a module or bundle, not on window | Widget solves, token exists, nothing happens | Assign it to window, or pass the function via turnstile.render() |
Mistyped option key (errorCallback, onSuccess) | Silently ignored | Use the exact hyphenated keys, quoted as string keys |
execution: "execute" or data-execution="execute" | Widget renders, no challenge runs | Call turnstile.execute("#container") when you want the token |
Container is display: none or zero-sized when an interactive challenge appears | Nothing happens, then timeout-callback fires | Keep the container visible; use appearance: "interaction-only" or an Invisible-mode widget instead of CSS |
CSP missing frame-src https://challenges.cloudflare.com | Iframe never loads, error-callback gets 200500 | Allow challenges.cloudflare.com in both script-src and frame-src |
Page opened over file:// | Widget never initialises | Serve over http:// or https:// |
| Hostname not listed for the sitekey | error-callback gets 110200 | Add the domain under Hostname Management |
api.js self-hosted, proxied or cached | Works, then breaks after a Cloudflare update | Load it only from https://challenges.cloudflare.com/turnstile/v0/api.js |
Stale widgetId after a re-render | getResponse and reset act on a widget that no longer exists | Store the id returned by the most recent turnstile.render() |
A working explicit-render setup
This is the shape to reach for when a callback stops firing: one global entry point that Turnstile can find, the widget id captured, and every callback wired so no failure mode is silent.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <link rel="preconnect" href="https://challenges.cloudflare.com" /> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad" defer ></script> </head> <body> <form id="signup" action="/signup" method="POST"> <input type="email" name="email" required /> <div id="turnstile-container"></div> <button id="submit" type="submit" disabled>Create account</button> </form> <script> let widgetId = null; const submit = document.getElementById("submit"); // Must be global. The onload parameter is a name, resolved on window. window.onTurnstileLoad = function () { widgetId = turnstile.render("#turnstile-container", { sitekey: "1x00000000000000000000AA", // test key: always passes callback: function (token) { console.log("solved:", token.slice(0, 16)); submit.disabled = false; }, "error-callback": function (code) { console.error("turnstile error:", code); submit.disabled = true; return false; // falsy: let Turnstile log the code too }, "expired-callback": function () { submit.disabled = true; turnstile.reset(widgetId); }, "timeout-callback": function () { submit.disabled = true; console.warn("interactive challenge not solved in time"); }, }); }; </script> </body> </html>
Note the timeout-callback. If that one fires instead of callback, an interactive challenge was presented and nobody solved it — which is exactly what happens when the widget is rendered into a container the visitor cannot see or reach. Hiding a Managed-mode widget with CSS does not make it non-interactive; it makes it unsolvable. If you need verification with no visual footprint, use an Invisible-mode widget or appearance: "interaction-only", both of which are designed for it.
Multiple widgets need their own ids
turnstile.render() returns a widgetId. Every lifecycle call — getResponse, reset, isExpired, remove — takes that id. With one widget on the page you can get away with sloppiness. With two, you cannot.
The failure looks like a callback that fires for the wrong form. Under implicit rendering, each widget inside a <form> creates its own hidden cf-turnstile-response input scoped to that form, so document.querySelector('[name="cf-turnstile-response"]') returns whichever appears first in the document — usually not the one the visitor solved. Scope the lookup to the submitted form instead, or read the token from the callback argument and never touch the DOM.
The documented callback signature receives the token and nothing else, so a single shared data-callback="onSuccess" across two widgets gives you no way to tell which one fired. Either give each widget its own named callback, or render explicitly and close over the id:
const widgets = new Map(); function mount(selector, sitekey, onToken) { const id = turnstile.render(selector, { sitekey, callback: (token) => onToken(token, id), "error-callback": (code) => console.error(selector, code), }); widgets.set(selector, id); return id; } mount("#login-widget", SITEKEY, (t) => enableLogin(t)); mount("#newsletter-widget", SITEKEY, (t) => enableNewsletter(t)); // Later, reset only the one that failed server-side validation. turnstile.reset(widgets.get("#login-widget"));
turnstile.reset() also accepts the container selector, so turnstile.reset("#login-widget") works if you would rather not track ids. turnstile.remove() deletes the widget and all of its DOM, and — this matters when you are hunting a missing callback — it invokes no callback on the way out.
React, Vue and SPA remounts
Framework integrations produce a distinct version of this bug: the callback fires once, then never again after a re-render. Three things cause it.
- **StrictMode double-mounting.** React 18 and later run effects twice in development. Two
turnstile.render()calls hit the same container and you end up with an orphaned widget whose callback you are no longer listening to. Returnturnstile.remove(widgetId)from the effect cleanup. - **Unstable callback identity.** If you put
onTokenin the effect's dependency array and the parent recreates that function on every render, the widget is torn down and rebuilt constantly — often faster than a challenge can complete. Hold the callback in a ref and read it at call time so the effect can depend only on the sitekey. - **Stale ids after a route change.** Keep the id from the most recent
render(). AgetResponseagainst a removed widget returns nothing, which reads as "the callback never fired" when the real problem is that you are asking the wrong widget.
import { useEffect, useId, useRef, useState } from "react"; export function Turnstile({ sitekey, onToken }) { // useId returns strings containing ':', which is invalid in a CSS selector. const id = "ts-" + useId().replace(/:/g, ""); const [ready, setReady] = useState(() => Boolean(window.turnstile)); // Keep the latest callback without making the widget depend on it. const cb = useRef(onToken); cb.current = onToken; useEffect(() => { if (window.turnstile) { setReady(true); return; } const s = document.createElement("script"); s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; s.async = true; s.onload = () => setReady(true); document.head.appendChild(s); }, []); useEffect(() => { if (!ready) return; const widgetId = window.turnstile.render("#" + id, { sitekey, callback: (token) => cb.current(token), "error-callback": (code) => { console.error("turnstile error:", code); return false; }, }); // Cleanup runs on StrictMode's second pass and on unmount. return () => window.turnstile.remove(widgetId); }, [ready, id, sitekey]); return <div id={id} />; }
If you would rather not maintain this, @marsidev/react-turnstile and react-turnstile both wrap the same lifecycle. The failure modes above still apply — they are properties of the Turnstile API, not of any wrapper.
When you need a token without a browser doing the solving
Everything above assumes a real visitor in a real browser. Automated suites are a different problem: Cloudflare states plainly that Selenium, Cypress and Playwright are detected as bots, which is why the dummy sitekeys exist. For CI against your own build, use 1x00000000000000000000AA and the matching test secret key — it is the supported path and it costs nothing.
Test keys stop being an option once you are exercising the real sitekey: production smoke tests, uptime monitoring on a live signup flow, or checking that your own anti-bot configuration behaves as intended. SolveGate covers that case for properties you own or are authorised to test. One POST /v1/solve with the gate, sitekey and page URL returns a Turnstile token, typically in under 1.5 seconds, with the first 1,000 solves free and failed solves never billed. It handles Turnstile — managed, non-interactive, invisible — and Turnstile WAF challenge pages, and nothing else.
Common questions
The two common causes are that the widget never rendered, or that the named function is not in the global scope. data-callback takes a function name as a string, which Turnstile resolves on window; a function declared inside <script type="module"> or a bundled file is module-scoped and will not be found. Check document.querySelector('[name="cf-turnstile-response"]').value — if a token is there, the challenge worked and only the lookup failed.
With implicit rendering, yes. data-callback, data-error-callback and the onload parameter on api.js are all names resolved in the global scope, so the function must be reachable as window.yourFunction. With explicit rendering you pass the function itself to turnstile.render(), so scope does not matter — but the option keys must be exact, and the hyphenated ones such as error-callback must be quoted string keys.
Usually the widget is being mounted and torn down repeatedly. React StrictMode runs effects twice in development, and an unstable onToken in the dependency array rebuilds the widget on every parent render, often faster than a challenge can finish. Keep the callback in a ref, depend only on the sitekey, and return turnstile.remove(widgetId) from the effect cleanup.
Not reliably. A Managed-mode widget can escalate to an interactive challenge, and a visitor cannot click a container that is display: none or zero-sized — so callback never fires and timeout-callback fires instead. If you need verification with no visible widget, use an Invisible-mode widget or appearance: "interaction-only" rather than hiding a visible widget with CSS.
Temporarily swap in the test sitekey 1x00000000000000000000AA, which always passes and works on any domain including localhost. If your callback fires with the dummy token XXXX.DUMMY.TOKEN.XXXX, the wiring is sound and the problem is with the real widget's configuration. Use 3x00000000000000000000FF to force an interactive challenge and reproduce timeout or hidden-container failures.
Each widget in a form creates its own hidden cf-turnstile-response input, so an unscoped document.querySelector returns the first one in the document rather than the one that was solved. The documented callback signature passes only the token, so a shared data-callback cannot tell you which widget fired. Render explicitly, keep the widgetId each turnstile.render() returns, and close over it in the callback.
Related
More in Turnstile errors
Automating a gate you own or are authorised to test?
// SolveGate clears Cloudflare Turnstile and WAF challenges through one REST call · first 1,000 solves free