This guide builds a Budibase automation that takes a row from a captcha_jobs table, submits its CAPTCHA to CaptchaAI, polls for the token and writes it back, using only a REST API connection and built-in steps. Two limits shape the design: the JavaScript step cannot wait or make HTTP calls, and a whole automation run is cut off after 120 seconds by default, so all waiting happens in Delay and Loop steps sized to that budget. Step names, defaults and limits were checked against the Budibase v3.47.0 source and the official docs in September 2026.
When this fits, and when it does not. Use it when an internal Budibase tool legitimately needs one CAPTCHA step handled on the server. A typical case is an ops form that files requests on a third-party portal your organization is authorized to use over HTTP, where the portal's form carries a reCAPTCHA v2 or Cloudflare Turnstile widget. It does not fit flows tied to a browser session (challenges that set cookies, run page scripts or need clicks), because a Budibase automation has no way to place a token into a page open in someone's browser. Use the CaptchaAI browser extension or a Playwright script for those. Cloudflare Challenge is out of scope too: it needs a proxy at submit time and the same IP and User-Agent on every later request.
The pieces
- A REST API connection, CaptchaAI, with two queries:
submitTask(POST/in.php) andgetResult(GET/res.php). - A
captcha_jobstable that serves as job queue and audit trail. - A Row created automation that submits, waits, polls, and posts the token to the portal through a second connection.
- A small alert automation for when CaptchaAI has no free thread.
Test both calls with curl first
Prove that the key, sitekey and page URL work before building anything in Budibase. The sitekey is the data-sitekey on the portal's form, or the k parameter of the reCAPTCHA anchor URL; the reCAPTCHA v2 guide shows where to find it.
#!/usr/bin/env bash
# Exercise the two calls the Budibase queries will make. Needs curl and jq.
set -uo pipefail
: "${CAPTCHAAI_KEY:?export CAPTCHAAI_KEY=YOUR_API_KEY first}"
: "${SITEKEY:?export SITEKEY (the data-sitekey value)}"
: "${PAGEURL:?export PAGEURL to the page that shows the widget}"
# 1. submitTask: the same fields the x-www-form-encoded body will send
submit=$(curl -s https://ocr.captchaai.com/in.php \
--data-urlencode "key=${CAPTCHAAI_KEY}" \
--data-urlencode "method=userrecaptcha" \
--data-urlencode "googlekey=${SITEKEY}" \
--data-urlencode "pageurl=${PAGEURL}" \
--data-urlencode "json=1")
echo "in.php -> ${submit}"
task_id=$(jq -r 'select(.status == 1) | .request' <<<"$submit" 2>/dev/null)
[ -n "$task_id" ] || { echo "submit rejected, fix the request before wiring Budibase"; exit 1; }
# 2. getResult: wait like the Delay step, then poll every 5 s
sleep 15
for attempt in $(seq 1 14); do
poll=$(curl -s -G https://ocr.captchaai.com/res.php \
--data-urlencode "key=${CAPTCHAAI_KEY}" \
-d action=get -d "id=${task_id}" -d json=1)
request=$(jq -r '.request // empty' <<<"$poll" 2>/dev/null || printf '%s' "$poll")
if [ "$(jq -r '.status' <<<"$poll" 2>/dev/null)" = "1" ]; then
echo "attempt ${attempt}: solved, token starts ${request:0:40}"
exit 0
fi
echo "attempt ${attempt}: ${request}"
[ "$request" = "CAPCHA_NOT_READY" ] || exit 1
sleep 5
done
echo "no answer within the polling budget"
exit 1
What those two calls do, as CaptchaAI's API docs define them:
in.phptakeskey,method=userrecaptcha,googlekey,pageurlandjson=1. Withjson=1it answers{"status":1,"request":"<task ID>"}or{"status":0,"request":"ERROR_..."}. For Turnstile, sendmethod=turnstilewithsitekeyinstead ofgooglekey(Turnstile walkthrough).res.phptakeskey,action=get, the task ID asid, andjson=1. It returnsCAPCHA_NOT_READY(the API spells it without the T) until the token is ready, then{"status":1,"request":"<token>"}.- Timing. The docs suggest a 15–20 second wait before the first reCAPTCHA v2 poll (10–15 for Turnstile), then a poll every 5 seconds. The reasoning behind those numbers is in the CAPCHA_NOT_READY polling guide.
- Plain-text errors. An error can come back as plain text even when
json=1was sent. Task IDs are digits, but store them as text.
The captcha_jobs table
A form on a screen creates each row with method, sitekey, pageurl and status set to new. The automation writes the rest.
| Column | Budibase type | Holds |
|---|---|---|
method |
Single select: userrecaptcha, turnstile |
Which automation picks the row up |
sitekey |
Text | The widget's sitekey |
pageurl |
Text | The page that shows the widget |
task_id |
Text | The ID in.php returned |
status |
Single select: new, pending, solved, expired, failed, delivered |
Where the job is |
result |
Long form text | The token |
attempts |
Number | Polls made so far |
error |
Text | The last CaptchaAI code, or a local note |
created_at |
Auto column (Created At) | Audit timestamp |
Connect CaptchaAI as a REST API
In current Budibase go to Workspace Settings > Connections > APIs, choose Add connection > Create custom, set the Display name to CaptchaAI and the Base URL to https://ocr.captchaai.com, then save (Budibase REST docs).
Where the key lives. Budibase runs REST queries on its server, so the key is safe as long as it never passes through a screen:
- Best: an environment variable (Settings > Environment), referenced as
{{ env.CAPTCHAAI_KEY }}. This is a paid feature; the free tier shows it locked. Stored values are encrypted and cannot be read back. Each variable holds a development value, used by the builder, and a production value, used by the published app, so make sure both hold a valid key. - Otherwise: a static variable on the connection's Credentials tab. Do not also declare a query binding with the same name, because a non-empty binding value overrides a static variable.
- Never: a query binding. Binding values are supplied by whatever runs the query, and a screen or button sends them from the user's browser.
Also set each query's Access to your most restricted role. Automation steps call the query directly and skip that check, while any app user whose role meets it could otherwise run submitTask from the browser and tie up your threads.
Now open the saved connection with Open in API Editor and add two queries to it. The editor gives each query Bindings, Params, Headers, Body and Transformer tabs.
submitTask: POST, path/in.php, body typex-www-form-encodedwith the pairskey={{ env.CAPTCHAAI_KEY }},method=userrecaptcha,googlekey={{ sitekey }},pageurl={{ pageurl }}andjson=1. Declare the bindingssitekeyandpageurl.getResult: GET, path/res.php, with the paramskey={{ env.CAPTCHAAI_KEY }},action=get,id={{ id }}andjson=1. Declare the bindingsid,attempt(default1) andmaxAttempts(default14). The last two are never sent to CaptchaAI; only the transformer reads them.- Turnstile: duplicate
submitTaskassubmitTurnstile, withmethod=turnstileand the pair renamed fromgooglekeytositekey.
One transformer for both queries
Budibase's REST integration parses a response as JSON only when its Content-Type is application/json (XML is parsed too); a plain-text or HTML response reaches the transformer as raw text. Together with CaptchaAI's occasional plain-text errors, that means a query must accept both shapes. The function below does, and returns one flat object for the automation to bind to. Paste the function (not the local check under it) into each query's Transformer and end with return normaliseCaptchaAi(data, params); Budibase provides data and params to every transformer.
// Transformer for both CaptchaAI queries (submitTask and getResult).
// Paste the function into each query's Transformer, then add this last line:
// return normaliseCaptchaAi(data, params)
function normaliseCaptchaAi(data, params) {
// Budibase passes a parsed object when the response is served as
// application/json and the raw text otherwise, so accept both.
let body = Array.isArray(data) ? data[0] : data;
if (typeof body === "string") {
const text = body.trim();
try {
body = JSON.parse(text);
} catch (err) {
body = text.startsWith("OK|")
? { status: 1, request: text.slice(3) }
: { status: 0, request: text };
}
}
body = body || {};
const code = String(body.request ?? "");
const isPoll = Boolean(params && params.id); // only getResult has an id binding
if (Number(body.status) === 1) {
// submit: code is the task ID; poll: code is the token
return { ok: true, value: code, error: "", rowStatus: isPoll ? "solved" : "pending" };
}
const keepPolling =
code === "CAPCHA_NOT_READY" ||
code === "ERROR_SERVER_ERROR" ||
code === "ERROR_INTERNAL_SERVER_ERROR" ||
code === "" ||
code.startsWith("<"); // an HTML 5xx page instead of an answer
if (isPoll && keepPolling) {
const attempt = Number(params.attempt || 1);
const maxAttempts = Number(params.maxAttempts || 14);
if (attempt >= maxAttempts) {
return { ok: false, value: "", error: "no answer within the polling budget", rowStatus: "expired" };
}
const note = code === "CAPCHA_NOT_READY" ? "" : code.slice(0, 80);
return { ok: false, value: "", error: note, rowStatus: "pending" };
}
// ERROR_ZERO_BALANCE, key errors, bad parameters, ERROR_CAPTCHA_UNSOLVABLE...
return { ok: false, value: "", error: code.slice(0, 120), rowStatus: "failed" };
}
// Local check with Node (save as transformer.mjs, run node transformer.mjs).
if (typeof process !== "undefined" && process.versions && process.versions.node) {
const cases = [
["submit accepted", { status: 1, request: "74965409378" }, {}],
["submit, no free threads", '{"status":0,"request":"ERROR_ZERO_BALANCE"}', {}],
["poll, not ready", { status: 0, request: "CAPCHA_NOT_READY" }, { id: "74965409378", attempt: "3" }],
["poll, plain-text answer", "OK|03AGdBq2...", { id: "74965409378", attempt: "4" }],
["poll, last attempt", { status: 0, request: "CAPCHA_NOT_READY" }, { id: "74965409378", attempt: "14" }],
["poll, unsolvable", "ERROR_CAPTCHA_UNSOLVABLE", { id: "74965409378", attempt: "6" }],
];
for (const [label, data, params] of cases) {
console.log(label.padEnd(24), JSON.stringify(normaliseCaptchaAi(data, params)));
}
}
The local check prints:
submit accepted {"ok":true,"value":"74965409378","error":"","rowStatus":"pending"}
submit, no free threads {"ok":false,"value":"","error":"ERROR_ZERO_BALANCE","rowStatus":"failed"}
poll, not ready {"ok":false,"value":"","error":"","rowStatus":"pending"}
poll, plain-text answer {"ok":true,"value":"03AGdBq2...","error":"","rowStatus":"solved"}
poll, last attempt {"ok":false,"value":"","error":"no answer within the polling budget","rowStatus":"expired"}
poll, unsolvable {"ok":false,"value":"","error":"ERROR_CAPTCHA_UNSOLVABLE","rowStatus":"failed"}
Budibase turns a transformer result that is not an array into a one-element array. The API request step's output therefore looks like the JSON below (trimmed), which is why every binding in the next section contains .0.:
{
"response": [
{ "ok": true, "value": "03AGdBq2...", "error": "", "rowStatus": "solved" }
],
"info": { "code": 200 },
"success": true
}
Nothing retries automatically on a failed submit. CaptchaAI's docs warn that repeating a request that returned a key or parameter error risks account suspension, and repeated bad keys earn an IP_BANNED that lasts 5 minutes. A failed row waits for a person; the error code reference explains each code.
Build the automation
Create an automation called Solve reCAPTCHA job with the Row created trigger on captcha_jobs. Under Filtering, add method equals userrecaptcha and status equals new. Rename each step as shown so the bindings below match what the binding drawer offers: it lists a renamed step's outputs under the new name and brackets names with spaces (steps.[Poll result]).
- submit is an API request step running
submitTask, withsitekey={{ trigger.row.sitekey }}andpageurl={{ trigger.row.pageurl }}. Some docs pages call this step External Data Connector. - saveSubmit is an Update row step on
captcha_jobswith Row ID{{ trigger.id }}. Settask_id={{ steps.submit.response.0.value }},status={{ steps.submit.response.0.rowStatus }}anderror={{ steps.submit.response.0.error }}. - accepted is a Condition:
{{ steps.submit.response.0.rowStatus }}Equalspending. A rejected task stops the run here, and the logs show it as Stopped. - firstWait is a Delay: 15 Seconds.
- pollLoop is a Loop with Binding / Value
[1,2,3,4,5,6,7,8,9,10,11,12,13,14]and Max loop iterations14. Inside it:- loadJob, a Get row step with Row ID
{{ trigger.id }}. - stillPending, a Condition:
{{ steps.loadJob.row.status }}Equalspending. - pollWait, a Delay of 5 Seconds.
- poll, an API request running
getResult, withid={{ steps.loadJob.row.task_id }},attempt={{ loop.currentItem }}andmaxAttempts=14. - savePoll, an Update row on Row ID
{{ trigger.id }}that setsstatus,resultanderrorfrom{{ steps.poll.response.0.rowStatus }},.valueand.error, plusattempts={{ loop.currentItem }}.
- loadJob, a Get row step with Row ID
- finalJob is a Get row step on Row ID
{{ trigger.id }}, then solved, a Condition:{{ steps.finalJob.row.status }}Equalssolved. The delivery steps in the next section follow it.
For Turnstile rows, duplicate the automation as Solve Turnstile job. Filter it on method equals turnstile, point submit at submitTurnstile, and shorten firstWait to 10 seconds, the low end of the docs' 10–15 second Turnstile wait.
How the loop behaves, and why it is built this way:
- What it repeats. Every step inside runs once per item of Binding / Value; here the items are attempt numbers, exposed as
{{ loop.currentItem }}. - Why Failure Condition cannot end it. It compares each item, not a step's output, so it cannot stop the loop when the token arrives.
- What a failed Condition does. A failed Condition inside a Loop skips the rest of that iteration and moves to the next item; it does not end the loop. That is why
loadJobandstillPendingcome first: oncesavePollwritessolved,failedorexpired, each remaining iteration costs a single row read. - What can go inside. The step picker offers Delay inside a Loop but hides Branch, Collect Data and another Loop. The Budibase looping docs cover the Binding, Max iterations and Failure Condition fields.
- The cap. Max loop iterations cannot exceed
AUTOMATION_MAX_ITERATIONS(200 by default). If the binding holds more items than the cap, the Loop step fails when it reaches the cap.
The 120-second budget. The whole run, Delays included, must finish within AUTOMATION_THREAD_TIMEOUT: 120,000 ms by default, or QUERY_THREAD_TIMEOUT if that is set higher. Fifteen seconds of first wait plus 14 rounds of a 5-second Delay, one poll and two row operations lands at roughly 100 seconds, leaving room for a slow submit. If your jobs regularly need longer, raise the variable on a self-hosted install and lengthen the binding. Keep Max loop iterations and the maxAttempts value that poll passes equal to the binding's length, or the transformer marks the row expired at attempt 14 while the loop is still running. On older releases whose Loop wraps only one step, unroll it: copy Delay, poll and savePoll as many times as the budget allows.
Why not a JavaScript step? Budibase's docs state that "JavaScript within Budibase is server-side synchronous only" and that "Async interactions (such as API calls, or await) aren't supported" (Budibase JavaScript docs). Each execution is capped by JS_PER_EXECUTION_TIME_LIMIT_MS (1,500 ms by default in v3.47.0). A wait loop in JavaScript ends in "Timed out while executing JS". Use JavaScript to map values, and Delay to wait.
Testing. Builder test runs cap every Delay at 3 seconds, and automations in the development workspace run as tests. A test from the builder can therefore end with the row marked expired long before a real run would. Publish, then create a row in the published app to see real timings; automation changes only take effect after you publish.
Deliver the token immediately
Add a second REST connection whose base URL is the portal's origin. Point the query at the endpoint that finally accepts the form: Budibase follows same-origin redirects but fails the step with "Redirect to a different origin is not permitted." when the portal redirects to another host. Its query, say fileRequest, posts the portal's own form fields plus g-recaptcha-response = {{ token }} (for Turnstile, cf-turnstile-response). After solved, add an API request step named deliver with token = {{ steps.finalJob.row.result }}. Follow it with an Update row that sets status to delivered and error to HTTP {{ steps.deliver.info.code }}.
Timing is the whole point. Google states that "each reCAPTCHA user response token is valid for two minutes, and can only be verified once" (reCAPTCHA verification docs). Cloudflare gives Turnstile tokens 300 seconds and a single validation (Turnstile server-side validation). Post from the same run and never cache tokens; the expired-token guide covers the failure modes. Also note that Budibase re-runs a REST query once when the response status is 400 or above, so a request the portal rejects with a 4xx or 5xx reaches it twice.
Thread sizing and the no-thread alert
Each job holds one CaptchaAI thread from submit until its answer arrives. Plans are sized in threads with unlimited solves per thread, so BASIC ($15/month, 5 threads) keeps five jobs in flight at once (thread-based pricing). When every thread is busy, in.php can return ERROR_ZERO_BALANCE, which CaptchaAI's API page describes as "You don't have free threads". The code also appears when no plan is active. The transformer marks such rows failed with that code. Saturation does not always surface as an error, though: CaptchaAI may queue the extra task instead, and then you see a longer run of CAPCHA_NOT_READY and more rows ending expired. Keep the number of new rows created at once at or below your thread count.
For the alert, create a second automation with the Row updated trigger on captcha_jobs, Filtering error equals ERROR_ZERO_BALANCE, and a Send Email (SMTP) step to the admin. That step stays disabled until SMTP is configured. Because another automation changed the row, this relies on Chain automations ("Allow automations to trigger from other automations"), which is on by default when self-hosted and off on Budibase Cloud.
Returning the outcome to a button
If the person filling in the form should see the outcome on the same screen, make the automation synchronous. Synchronous automations need a Premium or Enterprise license (synchronous automation docs).
- Trigger and last step. Use the On demand trigger and end with a Collect Data step, which must be the last step.
- Bindings. Declare
sitekeyandpageurlunder the trigger's Fields. The button's values then arrive as{{ trigger.fields.sitekey }}and{{ trigger.fields.pageurl }}instead oftrigger.row. - Audit row. Start with a Create row step for the audit row and use its
idoutput wherever the steps above use{{ trigger.id }}. Create it withstatus=pending, notnew. Otherwise, with chaining on, the new row also fires Solve reCAPTCHA job and a second task is submitted. - Timeout. When a button's Trigger automation action selects such an automation, it shows "This automation will run synchronously" and a Timeout field, in seconds and capped at 120. Set it to 120. The value becomes the run's time limit, and the flow above needs about 100 seconds.
- What to collect. Collect the delivery result, not the token, because the token is spent the moment the automation posts it.
Self-hosting notes
- Limits are environment variables on the app service (docker-compose or Helm values). The v3.47.0 defaults are
AUTOMATION_MAX_ITERATIONS200,AUTOMATION_THREAD_TIMEOUT120000 andJS_PER_EXECUTION_TIME_LIMIT_MS1500. Budibase Cloud does not expose them. - Egress. The app service makes every call, so it needs outbound HTTPS to
ocr.captchaai.comand to the portal. - Debugging. An automation's Logs panel lists runs as Success, Stopped, Error or Stopped - Error and shows each step's output, the quickest way to confirm a binding path. History appears only for published apps, and the free plan keeps it for one day.
Same pattern in ToolJet
ToolJet Workflows (a paid feature) have the same building blocks with one gap:
- Submit. A Start node receives the job from an app's Run Workflow query or a webhook, a REST API data-source node calls
in.php, and a ToolJetDB node stores the task ID. - The gap. ToolJet's node reference lists no wait or delay node, so one run cannot pause between polls the way Budibase's Delay does.
- Poll. A second workflow with a Scheduler trigger in Interval mode lists pending jobs with a ToolJetDB node, runs the
getResultcall as a Loop node's looped function with{{value.task_id}}, maps each response in a JavaScript node (same logic as the transformer; it mustreturn), and routes solved tokens through an If condition node to the portal. - Timing. Intervals are counted in minutes, which eats into reCAPTCHA's two-minute token life, so post each token from the run that receives it. The n8n and Retool versions of this flow show other trade-offs.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| A binding resolves to nothing | Query results are arrays | Use steps.poll.response.0.rowStatus, and check the step's output in Logs |
| "Timed out while executing JS" | Waiting or looping inside JavaScript | Wait with Delay; keep JavaScript for mapping |
Every builder test ends expired |
Test runs cap Delay at 3 seconds | Publish and test in the published app |
The run times out and the row stays pending |
The run exceeded AUTOMATION_THREAD_TIMEOUT |
Shorten the binding, or raise the variable when self-hosted |
The run errors at submit and the row stays new |
The app service cannot reach ocr.captchaai.com |
Allow outbound HTTPS from the app service, then re-create the row |
| The key shows in browser dev tools | The key was a query binding filled by a screen | Move it to an environment or static variable |
Rows end expired in the published app |
14 polls passed without a token, often because every thread was busy and the task queued | Create fewer rows at once, or lengthen the binding, raise maxAttempts and Max loop iterations to match, and raise AUTOMATION_THREAD_TIMEOUT when self-hosted |
| Two CaptchaAI tasks for one row | The reCAPTCHA and Turnstile automations lack a method filter, or a synchronous automation's Create row wrote status = new |
Add the Filtering on method to each; create synchronous rows as pending |
| The alert automation never fires | Chain automations is off (the Cloud default) | Turn on Enable chaining in the workspace's Automations settings |
FAQ
Can CaptchaAI call my Budibase automation back when the token is ready?
No. CaptchaAI delivers results only when you poll res.php; no callback URL is documented. Polling from a Delay and Loop pair is the intended pattern.
Does the same automation handle reCAPTCHA v3?
Yes, with a copy of submitTask that adds version=v3 and the page's action value. Leave proxies out: CaptchaAI's proxy guide lists standard v3 as not supported with one. The token goes to the portal as g-recaptcha-response, as with v2.
Next step
Get a CaptchaAI API key, run the curl test with your portal's sitekey, then build submitTask and getResult and wire them into the automation above.