A FlutterFlow app can use CaptchaAI without the API key ever reaching a phone: the app sends a page URL and sitekey to a backend function, which submits the task, polls for the answer, and returns the token or uses it on the spot. This guide builds that function on Firebase, on Supabase, and with private API calls, each sized to its time limit. Time is what breaks first: a reCAPTCHA solve can outlast a minute, and the call FlutterFlow generates for a Cloud Function stops waiting after 60 seconds.
The examples assume forms that your organization runs or has written permission to automate, such as a legacy web portal with no API that your field app has to file reports into.
Why the key cannot ship in the app
Anything compiled into a Flutter build can be pulled back out of it, including an API call whose parameters carry the key and a custom action that holds it in a string. Whoever extracts the key can run tasks on your plan's threads. Keep the Dart client from our Dart and Flutter guide for server-side Dart and internal tools, not for a binary you publish to an app store.
For ordinary APIs, FlutterFlow's fix is the Make Private toggle in an API call's Advanced Settings. The API call documentation says a private call is routed through a Cloud Function in your Firebase project, named ffPrivateApiCall by default, so values in the call definition stay off the device. That covers one request. A full solve is a submit to in.php, a wait, and several res.php polls, and a private call can neither wait nor loop. The app would have to drive the polling, one round trip per poll, and the token would still land on the phone. A function you write can do the whole exchange on the server.
| Option | Where the key lives | Hard time limit | Best use |
|---|---|---|---|
| Firebase Cloud Function (your code) | Secret Manager, bound to the function | The Timeout (s) setting, up to 540 s on 1st gen; FlutterFlow's generated call waits 60 s | Full solve, and spending the token server-side |
| Supabase Edge Function | Edge Function secret | 150 s before the caller receives a 504 | Projects whose backend is Supabase |
| Private API call | The FlutterFlow call definition, deployed as ffPrivateApiCall |
One HTTP request per call | The submit step, and app-driven polling as a last resort |
Firebase: the solveCaptcha function
Before you open the editor
FlutterFlow's Cloud Functions guide lists two prerequisites: the Firebase project must be on the Blaze plan, and FlutterFlow's Firebase setup must be complete. The generated boilerplate uses the 1st-gen firebase-functions API (runWith, onCall((data, context) => ...)), so 1st-gen rules apply. Before the first deploy, create the secret the function will read:
firebase functions:secrets:set CAPTCHAAI_API_KEY --project your-firebase-project-id
The CLI prompts for the value, the 32-character key from your CaptchaAI dashboard, and warns that functions must be redeployed to pick it up. The Secret Manager page of the Google Cloud console works too.
Boilerplate Settings
In FlutterFlow, open Cloud Functions from the Navigation Menu, click + Add, name the function solveCaptcha, and set the Boilerplate Settings panel:
- Memory Allocation: 256 MB. The function mostly waits on HTTP, so more memory only raises the bill.
- Timeout (s): 180. The code waits 15 s, polls for up to 120 s, and needs margin for the submit and the last poll.
- Require Authentication: on. The code also checks
context.authitself and answersUNAUTHENTICATED, so the check does not depend on how the setting is enforced. - Cloud Function Region: the same as your Default GCP resource location and the Cloud Function Region in FlutterFlow's Firebase Advanced Settings, as FlutterFlow's guide recommends.
Create a Data Type CaptchaResult with String fields status, token and error, enable Return Value and pick it; FlutterFlow maps a returned JSON object onto a custom Data Type by field name. Then use + Add parameters for three Strings: method, sitekey and pageurl.
Click the [</>] icon, then Copy to Editor. The boilerplate writes the region(...) and runWith({...}) lines from your settings; keep them and add secrets: ['CAPTCHAAI_API_KEY'] to runWith. FlutterFlow's guide tells you to regenerate the boilerplate whenever settings or parameters change, and copying a fresh one over your code drops that addition, so add it back each time.
The code
const functions = require('firebase-functions');
const API = 'https://ocr.captchaai.com';
// Pages your organization runs or is authorized to automate. Nothing else gets solved.
const ALLOWED_HOSTS = new Set(['portal.example.com']);
// The allowed CaptchaAI methods, and the field each one uses for the sitekey.
const FIELD = new Map([['userrecaptcha', 'googlekey'], ['turnstile', 'sitekey']]);
const KEEP_POLLING = new Set(['CAPCHA_NOT_READY', 'BAD_RESPONSE', 'ERROR_INTERNAL_SERVER_ERROR']);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const failed = (error) => ({ status: 'failed', token: '', error });
// CaptchaAI can answer in plain text even with json=1, so never trust res.json().
async function captchaai(url, init = {}) {
let text = '';
try {
text = (await (await fetch(url, { ...init, signal: AbortSignal.timeout(10000) })).text()).trim();
const body = JSON.parse(text);
if (body == null || body.request == null) throw new Error('unexpected shape');
return { status: Number(body.status), request: String(body.request) };
} catch {
return { status: 0, request: /^(ERROR_\w+|IP_BANNED)$/.test(text) ? text : 'BAD_RESPONSE' };
}
}
function hostOf(url) {
try { return new URL(String(url)).hostname; } catch { return ''; }
}
exports.solveCaptcha = functions
.region('us-central1')
.runWith({ timeoutSeconds: 180, memory: '256MB', secrets: ['CAPTCHAAI_API_KEY'] })
.https.onCall(async (data, context) => {
// Failures go back as data: FlutterFlow's Cloud Function action keeps only a thrown error's code.
if (!context.auth) return failed('UNAUTHENTICATED');
const { method, sitekey, pageurl } = data || {};
if (!FIELD.has(method) || typeof sitekey !== 'string' || !sitekey || !ALLOWED_HOSTS.has(hostOf(pageurl))) {
return failed('BAD_INPUT');
}
const key = (process.env.CAPTCHAAI_API_KEY || '').trim();
if (!key) return failed('MISSING_SECRET');
const form = new URLSearchParams({ key, method, pageurl, json: '1' });
form.set(FIELD.get(method), sitekey);
const submit = await captchaai(`${API}/in.php`, { method: 'POST', body: form });
if (submit.status !== 1) return failed(submit.request);
await sleep(15000); // first poll: 15-20 s for reCAPTCHA v2, 10-15 s for Turnstile
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
const query = new URLSearchParams({ key, action: 'get', id: submit.request, json: '1' });
const poll = await captchaai(`${API}/res.php?${query}`);
if (poll.status === 1) return { status: 'ready', token: poll.request, error: '' };
if (!KEEP_POLLING.has(poll.request)) return failed(poll.request);
await sleep(poll.request === 'ERROR_INTERNAL_SERVER_ERROR' ? 10000 : 5000);
}
return failed('TIMEOUT');
});
A few choices here are deliberate:
- Failures are returned, not thrown. The Dart that FlutterFlow generates for the Cloud Function action catches a
FirebaseFunctionsExceptionand keeps only its code: a code export storessucceeded: falseanderrorCode: error.codeand drops the message and details, so everyCaptchaResultfield stays empty. A thrownHttpsErrorcarryingERROR_ZERO_BALANCEwould reach your action flow as a barefailed-preconditionorinternal, so the function puts the CaptchaAI code, orUNAUTHENTICATED,BAD_INPUTorMISSING_SECRET, inerrorinstead. FIELDdoubles as the method allowlist.userrecaptchasends the sitekey asgooglekey,turnstileassitekey, and anything else is refused before a task exists.ALLOWED_HOSTSprotects your plan. Without it, anyone who can sign in to your app could use the function as a general-purpose solver.captchaai()reads text before parsing. CaptchaAI can answer in plain text even withjson=1, and rarely returns an HTML 500 or 502 page. While polling, a garbled answer means "retry in 5 seconds" andERROR_INTERNAL_SERVER_ERRORmeans "retry in about 10", not "give up". The 10-second fetch timeout stops one hung connection from eating the function's budget.- The timings follow CaptchaAI's docs: first poll after 15 to 20 seconds for reCAPTCHA v2 (10 to 15 for Turnstile), then every 5 seconds. The not-ready string is
CAPCHA_NOT_READY, with no T after CAP, so compare against exactly that spelling. - The key is trimmed, because a trailing newline from a piped value makes it 33 characters, and CaptchaAI rejects a key that isn't 32 with
ERROR_WRONG_USER_KEY.
package.json needs no new dependency: the engines entry FlutterFlow writes is Node.js 18 or later, and those runtimes have a global fetch. Click Save Cloud Function, then Deploy.
FlutterFlow's Cloud Functions page documents secrets only for Supabase; for Firebase, runWith({ secrets }) is the mechanism Firebase documents for 1st-gen functions. After the first deploy, open the function in the Google Cloud console and confirm the secret is attached. If it isn't, the function answers MISSING_SECRET instead of sending an empty key. The function's runtime service account, which for 1st gen is the App Engine default service account, needs the Secret Manager Secret Accessor role on that secret. After rotating the key, redeploy, since the function keeps reading the old version until you do (the full routine is in our API key security guide).
Calling solveCaptcha from a page
Add a Boolean page state variable, isSolving. On the submit button, set it to true, add the Cloud Function action for solveCaptcha with its three parameters and an Action Output Variable Name such as captchaResult, then set isSolving back to false. Branch first on the output's Succeeded flag: it is false when the call itself failed, for example because the app stopped waiting, and then only the Firebase error code comes back. When it is true, read Set from Variable > Action Outputs > captchaResult and branch on status: ready or failed. Drive a spinner from isSolving and disable the button while it is true, since every tap starts a new task on your plan.
Turn each failure into something the user can act on (the full list is in the CaptchaAI error code reference):
error |
Message for the user | What to fix |
|---|---|---|
UNAUTHENTICATED |
"Please sign in again." | Enable the button only for signed-in users |
Succeeded is false with deadline-exceeded |
"Still working, try again shortly." | The app stopped waiting before the function finished; see the next section |
ERROR_ZERO_BALANCE |
"Busy right now, retry in a minute." | Every thread on the plan is in use, or the plan has lapsed |
ERROR_CAPTCHA_UNSOLVABLE, TIMEOUT, BAD_RESPONSE, ERROR_SERVER_ERROR, ERROR_INTERNAL_SERVER_ERROR |
"Verification didn't finish. Tap to retry." | Allow one fresh retry after about 10 seconds, never a loop |
ERROR_WRONG_USER_KEY, ERROR_KEY_DOES_NOT_EXIST, MISSING_SECRET, IP_BANNED |
"Service unavailable." | Fix the secret and redeploy. Until then, send no more calls: repeated bad-key requests earn an IP_BANNED that lasts 5 minutes |
BAD_INPUT, ERROR_PAGEURL, ERROR_GOOGLEKEY, ERROR_WRONG_GOOGLEKEY, ERROR_WRONG_SITEKEY, ERROR_BAD_TOKEN_OR_PAGEURL |
"Service unavailable." | The method, sitekey or page URL is missing, not allowlisted, or doesn't match the live widget |
The 60-second client timeout
The function may run for 180 seconds, but the caller has its own clock. The Dart FlutterFlow generates for the Cloud Function action calls httpsCallable('solveCaptcha') without options, and the cloud_functions package defaults HttpsCallableOptions to a 60-second timeout. A solve that finishes at 70 seconds then reaches the app as a failed call with the code deadline-exceeded, while the function logs a success and the token is lost. If your reCAPTCHA tests hit this, call the function from a custom action that sets the timeout itself:
// Custom Action "runSolveCaptcha": String arguments method, sitekey, pageurl;
// Return Value: Data Type CaptchaResult. FlutterFlow's automatic imports sit above.
import 'package:cloud_functions/cloud_functions.dart';
Future<CaptchaResultStruct> runSolveCaptcha(
String method,
String sitekey,
String pageurl,
) async {
final callable = FirebaseFunctions.instanceFor(region: 'us-central1').httpsCallable(
'solveCaptcha',
// Wait past the function's 180 s Timeout, so the app gets the function's own
// answer (or its timeout error) instead of giving up at the default 60 s.
options: HttpsCallableOptions(timeout: const Duration(seconds: 185)),
);
try {
final result = await callable.call(<String, dynamic>{
'method': method,
'sitekey': sitekey,
'pageurl': pageurl,
});
final data = Map<String, dynamic>.from(result.data as Map);
return CaptchaResultStruct(
status: data['status'] as String?,
token: data['token'] as String?,
error: data['error'] as String?,
);
} on FirebaseFunctionsException catch (e) {
// e.code is a string such as 'deadline-exceeded', 'internal' or 'unavailable'.
return CaptchaResultStruct(status: 'failed', token: '', error: e.code);
}
}
Custom imports go below FlutterFlow's line "Do not remove or modify the code above". FlutterFlow's own code for the Cloud Function action already imports cloud_functions, so a project that uses that action has the package. Add it under Settings and Integrations > Project Dependencies > Custom Dependencies only if the editor still flags the import. The region must match the function's Cloud Function Region.
Spend the token inside the function
A token returned to the phone still has to be submitted somewhere, and its clock is running: Google's reCAPTCHA documentation gives a response token two minutes and one verification, and Cloudflare gives a Turnstile token 300 seconds and one use. The sturdier design has solveCaptcha submit the protected form itself and return only the outcome, so the token never travels to the device. For the portal example, add the form's fields as extra String parameters (vehicleId and notes), validate them like the others, and replace the status: 'ready' return with a call to this helper:
// In solveCaptcha, instead of returning the token:
// if (poll.status === 1) return submitToPortal(poll.request, data);
async function submitToPortal(token, data) {
const form = new URLSearchParams({
vehicle_id: String(data.vehicleId || ''),
notes: String(data.notes || ''),
'g-recaptcha-response': token, // a Turnstile form reads cf-turnstile-response instead
});
const res = await fetch('https://portal.example.com/inspections', {
method: 'POST',
body: form,
signal: AbortSignal.timeout(20000),
});
// Many portals answer 200 with an error page: check your portal's success marker too.
return res.ok
? { status: 'submitted', token: '', error: '' }
: { status: 'failed', token: '', error: `PORTAL_HTTP_${res.status}` };
}
If the form sets a session cookie or CSRF field when the page loads, fetch that page first in the same function and send its cookie back with the POST; a token submitted from a different session than the one expecting it is a common reason a correct solve is rejected. How the reCAPTCHA clock runs and what burns a token early is in our reCAPTCHA token lifecycle guide.
Split pattern: submit now, collect later
For progress in the UI, or to avoid depending on a custom action for the timeout, split the solve into two short callables: submitCaptcha returns a captcha ID within seconds, and getCaptchaResult polls once. Neither gets near the 60-second client limit, so FlutterFlow's standard Cloud Function action works for both. Create them as two Cloud Functions with a 30-second Timeout (s) and 128 MB Memory Allocation. submitCaptcha takes the same three String parameters as before, and getCaptchaResult takes one, captchaId. A code export puts each custom Cloud Function in its own file under firebase/custom_cloud_functions/, so each editor needs its own copy of the helpers; they are shown once here:
const functions = require('firebase-functions');
const API = 'https://ocr.captchaai.com';
const ALLOWED_HOSTS = new Set(['portal.example.com']);
const FIELD = new Map([['userrecaptcha', 'googlekey'], ['turnstile', 'sitekey']]);
const KEEP_POLLING = new Set(['CAPCHA_NOT_READY', 'BAD_RESPONSE', 'ERROR_INTERNAL_SERVER_ERROR']);
const short = functions.region('us-central1')
.runWith({ timeoutSeconds: 30, memory: '128MB', secrets: ['CAPTCHAAI_API_KEY'] });
const out = (status, captchaId = '', token = '', error = '') => ({ status, captchaId, token, error });
async function captchaai(url, init = {}) {
let text = '';
try {
text = (await (await fetch(url, { ...init, signal: AbortSignal.timeout(10000) })).text()).trim();
const body = JSON.parse(text);
if (body == null || body.request == null) throw new Error('unexpected shape');
return { status: Number(body.status), request: String(body.request) };
} catch {
return { status: 0, request: /^(ERROR_\w+|IP_BANNED)$/.test(text) ? text : 'BAD_RESPONSE' };
}
}
function hostOf(url) {
try { return new URL(String(url)).hostname; } catch { return ''; }
}
// The key, or '' when the caller isn't signed in or the secret isn't bound.
function keyFor(context) {
return context.auth ? (process.env.CAPTCHAAI_API_KEY || '').trim() : '';
}
exports.submitCaptcha = short.https.onCall(async (data, context) => {
const key = keyFor(context);
if (!key) return out('failed', '', '', context.auth ? 'MISSING_SECRET' : 'UNAUTHENTICATED');
const { method, sitekey, pageurl } = data || {};
if (!FIELD.has(method) || typeof sitekey !== 'string' || !sitekey || !ALLOWED_HOSTS.has(hostOf(pageurl))) {
return out('failed', '', '', 'BAD_INPUT');
}
const form = new URLSearchParams({ key, method, pageurl, json: '1' });
form.set(FIELD.get(method), sitekey);
const submit = await captchaai(`${API}/in.php`, { method: 'POST', body: form });
return submit.status === 1 ? out('pending', submit.request) : out('failed', '', '', submit.request);
});
exports.getCaptchaResult = short.https.onCall(async (data, context) => {
const key = keyFor(context);
if (!key) return out('failed', '', '', context.auth ? 'MISSING_SECRET' : 'UNAUTHENTICATED');
const captchaId = String((data && data.captchaId) || '');
if (!/^\d+$/.test(captchaId)) return out('failed', captchaId, '', 'BAD_INPUT');
const query = new URLSearchParams({ key, action: 'get', id: captchaId, json: '1' });
const poll = await captchaai(`${API}/res.php?${query}`);
if (poll.status === 1) return out('ready', captchaId, poll.request);
if (KEEP_POLLING.has(poll.request)) return out('pending', captchaId);
return out('failed', captchaId, '', poll.request);
});
Add a captchaId String field to CaptchaResult. When submitCaptcha returns pending, store the captcha ID in page state, add a Wait action of 15000 ms, then a Start Periodic Action every 5000 ms that calls getCaptchaResult and increments a counter. Call Stop Periodic Action on ready or failed, when the counter reaches 24 (two minutes), and before navigating away from the page; FlutterFlow's docs warn that a periodic action left running keeps consuming resources.
Captcha IDs are plain numbers. If users must not read each other's results, record each ID against context.auth.uid in Firestore and have getCaptchaResult check ownership before it polls. FlutterFlow's boilerplate warns against calling admin.initializeApp() in your code, because the generated index.js already does:
const admin = require('firebase-admin'); // the generated index.js calls initializeApp()
const tasks = () => admin.firestore().collection('captchaTasks');
// submitCaptcha calls this after a successful submit.
async function recordTask(captchaId, uid) {
await tasks().doc(captchaId).set({
uid,
status: 'pending',
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
}
// getCaptchaResult calls this before polling, and answers NOT_YOURS when it is false.
async function ownsTask(captchaId, uid) {
const snap = await tasks().doc(captchaId).get();
return snap.exists && snap.get('uid') === uid;
}
// getCaptchaResult calls this once the answer is final.
async function finishTask(captchaId, status) {
await tasks().doc(captchaId).update({ status });
}
Other screens can follow the same document through a Backend Query with Single Time Query off, which keeps the query live as the function updates it. Pair it with a Firestore rule that lets a user read only documents whose uid matches request.auth.uid.
Supabase: the same solve in an Edge Function
On a Supabase project, the function can live next to your database. In Cloud Functions, click + and choose Supabase Edge Function. Turn on Enable CORS for web builds. Leave Verify JWT on, but don't treat it as a sign-in check. It validates the JWT in the request's Authorization header, and a project's legacy anon key, which ships inside your app and is sent when nobody is signed in, is itself a valid JWT. Supabase's reference adds that, for migration compatibility, the check also accepts the new publishable keys. The code below therefore asks Supabase Auth for the user behind the token. Store the key under Edge Functions > Secrets in the Supabase dashboard, or from a terminal where the Supabase CLI is linked to the project and the key is in the environment:
supabase secrets set CAPTCHAAI_API_KEY="$CAPTCHAAI_API_KEY"
The limit that shapes this version is the request idle timeout in Supabase's Edge Function limits: a function that hasn't responded within 150 seconds gets the caller a 504 Gateway Timeout, whatever the plan's wall-clock allowance (150 s free, 400 s paid). The code therefore stops polling 120 seconds after the request arrives. The 2-second CPU limit is no problem, because it excludes time spent awaiting I/O.
// Supabase Edge Function "solve-captcha". Secret: CAPTCHAAI_API_KEY.
import { createClient } from "npm:@supabase/supabase-js@2";
const API = "https://ocr.captchaai.com";
const ALLOWED_HOSTS = new Set(["portal.example.com"]);
const FIELD = new Map([["userrecaptcha", "googlekey"], ["turnstile", "sitekey"]]);
const KEEP_POLLING = new Set(["CAPCHA_NOT_READY", "BAD_RESPONSE", "ERROR_INTERNAL_SERVER_ERROR"]);
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Every handled outcome is a 200: supabase_flutter throws on other statuses instead of returning the body.
const reply = (status: string, token = "", error = "") =>
new Response(JSON.stringify({ status, token, error }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
async function captchaai(url: string, init: RequestInit = {}) {
let text = "";
try {
text = (await (await fetch(url, { ...init, signal: AbortSignal.timeout(10_000) })).text()).trim();
const body = JSON.parse(text);
if (body == null || body.request == null) throw new Error("unexpected shape");
return { status: Number(body.status), request: String(body.request) };
} catch {
return { status: 0, request: /^(ERROR_\w+|IP_BANNED)$/.test(text) ? text : "BAD_RESPONSE" };
}
}
function hostOf(url: unknown): string {
try { return new URL(String(url)).hostname; } catch { return ""; }
}
// The project's own client key: the default publishable key, or the legacy anon key.
function projectKey(): string {
try {
const keys = JSON.parse(Deno.env.get("SUPABASE_PUBLISHABLE_KEYS") ?? "{}");
if (typeof keys.default === "string") return keys.default;
} catch { /* fall back to the legacy key */ }
return Deno.env.get("SUPABASE_ANON_KEY") ?? "";
}
// Verify JWT also lets the anon and publishable keys through, so look up the user behind the token.
async function signedIn(req: Request): Promise<boolean> {
const jwt = (req.headers.get("Authorization") ?? "").replace(/^Bearer\s+/i, "");
if (!jwt) return false;
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, projectKey(), {
auth: { persistSession: false },
});
const { data, error } = await supabase.auth.getUser(jwt);
return !error && !!data.user;
}
Deno.serve(async (req: Request) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
const started = Date.now();
if (!(await signedIn(req))) return reply("failed", "", "UNAUTHENTICATED");
const key = (Deno.env.get("CAPTCHAAI_API_KEY") ?? "").trim();
if (!key) return reply("failed", "", "MISSING_SECRET");
const { method, sitekey, pageurl } = (await req.json().catch(() => null)) ?? {};
const field = FIELD.get(method);
if (!field || typeof sitekey !== "string" || !sitekey || !ALLOWED_HOSTS.has(hostOf(pageurl))) {
return reply("failed", "", "BAD_INPUT");
}
const form = new URLSearchParams({ key, method, pageurl, json: "1" });
form.set(field, sitekey);
const submit = await captchaai(`${API}/in.php`, { method: "POST", body: form });
if (submit.status !== 1) return reply("failed", "", submit.request);
await sleep(15_000);
// Supabase answers 504 when nothing is sent within 150 s, so stop at 120 s.
while (Date.now() - started < 120_000) {
const query = new URLSearchParams({ key, action: "get", id: submit.request, json: "1" });
const poll = await captchaai(`${API}/res.php?${query}`);
if (poll.status === 1) return reply("ready", poll.request);
if (!KEEP_POLLING.has(poll.request)) return reply("failed", "", poll.request);
await sleep(poll.request === "ERROR_INTERNAL_SERVER_ERROR" ? 10_000 : 5_000);
}
return reply("failed", "", "TIMEOUT");
});
With Enable CORS on, the generated boilerplate brings its own corsHeaders and preflight handling; keep one set. SUPABASE_URL, SUPABASE_PUBLISHABLE_KEYS and the legacy SUPABASE_ANON_KEY are default secrets in hosted Edge Functions, so there is nothing to add for them. projectKey() prefers the publishable key because Supabase is deprecating the anon key by the end of 2026, and a disabled anon key would make every sign-in check fail. Trigger the function with the Edge Function action and read Action Outputs as with Firebase. The submitToPortal idea carries over unchanged.
Private API calls for the submit step
To submit without writing a function, define an API call: POST to https://ocr.captchaai.com/in.php, Body set to x-www-form-urlencoded (FlutterFlow defaults POST bodies to JSON; CaptchaAI expects form fields). Parameters: key as a Specific Value, method as the Specific Value userrecaptcha, googlekey and pageurl From Variable, json as the Specific Value 1. In Advanced Settings, turn on Make Private and Require Authentication, save, and click Deploy APIs. When $.status is 1, the JSON Path $.request holds the captcha ID.
The key must be a Specific Value: fed from app state, it is on the device again, private or not. Everyone with edit access to the FlutterFlow project can read it there. Private calls need Firebase connected even when your data lives in Supabase, and Require Authentication needs Firebase Authentication. A private call also can't run the ALLOWED_HOSTS check, so any signed-in user can send any sitekey and page URL through it, on your threads.
A private GET call to res.php (key, action=get, id, json=1), driven by the periodic-action loop above, finishes the solve without custom code, at the price of a full round trip per poll and a token that reaches the phone with its clock running. Fine for a prototype; your own function is easier to reason about. Builders with no code runtime are covered in our guide to Bubble, Softr, Webflow and Glide, and deploying with gcloud outside FlutterFlow in the Google Cloud Functions integration.
What waiting costs
Firebase compute. Google bills a 1st-gen function for time from request to completion, rounded up to 100 ms, including time idle on fetch. At 256 MB, which comes with 400 MHz of CPU, a call that spends 60 seconds mostly waiting uses 15 GB-seconds and 24 GHz-seconds: roughly $0.00028 at the Tier 1 rates on Google's 1st-gen pricing page (checked September 2026). The monthly free 200,000 GHz-seconds cover about 8,300 such calls. The split pattern trades that compute for more invocations, of which the first 2 million a month are free.
CaptchaAI threads. CaptchaAI bills by concurrent threads, not by solve. BASIC ($15/month, 5 threads) runs five tasks at once across every user of your app, with unlimited solves per thread. A sixth concurrent task can come back as ERROR_ZERO_BALANCE, meaning no free thread, or simply answer later. Size the plan to peak concurrency: if twelve inspectors file reports at the same moment every morning, you need STANDARD ($30/month, 15 threads) or a queue in front of the function. Our guide to thread-based pricing works through sizing in more detail; current plans are on the pricing page.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Deploy fails on a newly created project | Spark plan, or the project's Google Cloud APIs and build permissions were never set up | Confirm Blaze. FlutterFlow's FAQ fix: enable the Cloud Build, Cloud Functions and Cloud Run Admin APIs, deploy a sample function once in the Google Cloud console, and grant roles/cloudbuild.builds.builder to the default compute service account when prompted |
Deploy fails after adding secrets |
The secret doesn't exist in this project, or the runtime service account can't read it | Run firebase functions:secrets:set first, grant Secret Manager Secret Accessor to the App Engine default service account, then redeploy |
MISSING_SECRET |
A fresh boilerplate was copied over the secrets line |
Add secrets: ['CAPTCHAAI_API_KEY'] back to runWith and redeploy |
Succeeded is false with deadline-exceeded while the function log shows success |
The 60-second client timeout | Use the runSolveCaptcha custom action or the split pattern |
| CORS error in a web build | The function lacks its invoker permission | FlutterFlow's FAQ: grant Cloud Functions Invoker to allUsers; the code still refuses callers without context.auth |
ERROR_BAD_TOKEN_OR_PAGEURL |
The widget sits in an iframe from another domain | Send the iframe's URL as pageurl and allowlist its host |
| Supabase returns 504 | No response within 150 seconds | Keep the 120-second deadline; nothing slow before the submit |
| The portal rejects a fresh token | Different session from the page load, or expired | Fetch the form page in the same function and submit promptly |
FAQ
Is there a CaptchaAI package for Dart or FlutterFlow?
No. The API is plain HTTP form fields sent to in.php and res.php on ocr.captchaai.com, and the official SDK is a Python package. From FlutterFlow, call the API through a backend function as shown, never from the app.
Can the user just solve the CAPTCHA in a WebView instead?
When a person is present and the page opens in a WebView, the widget works as in a browser and CaptchaAI adds nothing. It fits flows where your backend submits on the user's behalf. For the WebView side, see our guide to CAPTCHAs in Flutter WebViews.
Should this function handle the reCAPTCHA on my app's own Firebase phone sign-in?
No. The Firebase Auth SDK owns that verifier, so there is no field to put a solved token in. Test phone sign-in with Firebase's fictional test numbers or the Auth emulator, as covered in our guide to testing CAPTCHA-protected sign-in.
Which CAPTCHA types can this function handle?
As written, reCAPTCHA v2 and Cloudflare Turnstile. reCAPTCHA v3 needs version=v3 plus the page's action. The Enterprise variants need enterprise=1, return the token in result rather than request, and return a user_agent the final submission must reuse, so extend the helper before adding them to FIELD. hCaptcha and FunCaptcha are not supported by CaptchaAI; keep refusing them.