A passed CAPTCHA is one checkpoint, not a qualified lead. First establish whether your server actually verified the challenge before accepting the submission. Then separate accepted form data, business receipt, contact verification and qualification. A working widget cannot settle all four questions.

This guide includes a runnable local lab: twelve HTTP submissions reach a controlled form, while a fixture verifier supplies chosen provider replies. You can see which requests create a record and which do not. It does not contact a CAPTCHA provider, solve a challenge or measure how well a vendor detects bots.

1. A browser success flag cannot authorize a lead

Inspect the server route that creates the enquiry, not just the visible form. Can a request reach that route without the widget? Does the route verify the token through the intended provider integration, or merely check that the browser sent a nonempty string? A client-supplied challenge_passed: true is input to validate, not a trusted decision.

Keep these checkpoints in order: parse and validate the form → verify the required challenge and expected context → accept and store the enquiry → deliver the business receipt → assess qualification. A failure before acceptance must not produce the same success response or conversion job as an accepted enquiry.

The fake-lead acceptance guide covers storage and duplicate submissions. The Cloudflare comparison separates website controls from applied ad exclusions and billing. A challenge result is not a refund decision.

2. Verify the provider result, not the widget animation

For Turnstile, Cloudflare requires server-side Siteverify validation. Tokens last five minutes, are single-use and have a maximum length of 2,048 characters. Check the returned boolean success and the hostname/action expected by your configured flow. Keep the secret on the server.

Handle rejection, an unavailable verifier and an invalid response deliberately. A timeout is an unknown verification result, not permission to create a lead. A provider-reported expiry or duplicate needs recovery; repeatedly sending the same spent token is not a new verification.

Cloudflare: mandatory validation, token lifetime, response fields and context checks, reviewed 3 November 2026. The lab below models application decisions with fixtures; it does not implement Siteverify.

Use the documentation for your exact product and version. Google’s classic reCAPTCHA verification guide also requires server verification and describes a two-minute, single-use token. Do not copy Turnstile’s lifetime or response assumptions into another provider’s integration.

Google: verifying a reCAPTCHA response.

3. Run the lab in an empty local folder

Use Python 3.10 or later. Download the acceptance-control lab into an empty working folder and run:

python3 challenge-acceptance-lab.py

The program binds only to 127.0.0.1, selects a temporary port and sends twelve JSON submissions to its own /lead route. It accepts no URL or account arguments, uses no secret, makes no external request and writes no report or database file. It prints JSON and closes its server.

The fixture verifier deliberately returns failure, timeout, malformed data, a string in place of a boolean, mismatched context, a chosen expiry failure and one positive result. The positive token can be used only once in this fixture. Expiry is not tested against a clock or a real provider.

Inspect the complete local program
"""Local acceptance-control lab, not a live CAPTCHA or a production form.

Python 3.10+. No arguments, secrets, dependencies, external requests or files.
Provider replies are fixtures. Only the local HTTP form boundary is exercised.
"""
import json
import re
import threading
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer


class FixtureVerifier:
    """Chosen replies demonstrate application handling, not bot detection."""

    def __init__(self):
        self.used = set()

    def verify(self, token):
        if token == 'fixture-timeout':
            raise TimeoutError('Chosen provider timeout')
        if token == 'fixture-malformed':
            return ['not', 'an', 'object']
        if token == 'fixture-string-success':
            return {'success': 'true'}
        if token == 'fixture-expired' or token in self.used:
            return {'success': False, 'error-codes': ['timeout-or-duplicate']}
        replies = {
            'fixture-wrong-host': ('other.example.test', 'lead'),
            'fixture-wrong-action': ('form.example.test', 'login'),
            'fixture-pass-once': ('form.example.test', 'lead'),
        }
        if token not in replies:
            return {'success': False, 'error-codes': ['invalid-input-response']}
        self.used.add(token)
        hostname, action = replies[token]
        return {'success': True, 'hostname': hostname, 'action': action}


class AcceptanceGate:
    def __init__(self, verifier):
        self.verifier = verifier
        self.leads = []
        self.pending_receipts = []

    # BEGIN ACCEPTANCE GATE
    def submit(self, payload):
        if not isinstance(payload, dict):
            return 400, 'invalid-payload'
        token = payload.get('token')
        if not isinstance(token, str) or not 1 <= len(token) <= 2048:
            return 400, 'missing-or-invalid-token'
        email = payload.get('email')
        # Deliberately only a format check: no ownership or deliverability proof.
        if not isinstance(email, str) or len(email) > 254 or not re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+', email):
            return 422, 'invalid-email-format'
        try:
            result = self.verifier.verify(token)
        except (TimeoutError, OSError):
            return 503, 'verification-unavailable'
        if not isinstance(result, dict) or type(result.get('success')) is not bool:
            return 503, 'invalid-verification-result'
        if result['success'] is not True:
            return 403, 'verification-rejected'
        if result.get('hostname') != 'form.example.test':
            return 403, 'hostname-mismatch'
        if result.get('action') != 'lead':
            return 403, 'action-mismatch'
        lead_id = 'local-lead-' + str(len(self.leads) + 1)
        self.leads.append({'id': lead_id, 'qualification': 'unreviewed'})
        self.pending_receipts.append({'lead_id': lead_id, 'state': 'pending'})
        return 202, 'accepted-for-review'
    # END ACCEPTANCE GATE


def run():
    gate = AcceptanceGate(FixtureVerifier())

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *_):
            pass

        def do_POST(self):
            if self.path != '/lead':
                self.send_error(404)
                return
            try:
                length = int(self.headers.get('Content-Length', '0'))
                if not 0 < length <= 8192:
                    raise ValueError('body-size')
                payload = json.loads(self.rfile.read(length).decode('utf-8'))
            except (ValueError, UnicodeDecodeError):
                status, decision = 400, 'invalid-json-or-body'
            else:
                status, decision = gate.submit(payload)
            body = json.dumps({'decision': decision}).encode('utf-8')
            self.send_response(status)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)

    server = HTTPServer(('127.0.0.1', 0), Handler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    good = {'email': 'not-a-real-inbox@example.test'}
    cases = [
        ('missing_token', good),
        ('client_approved_only', {**good, 'challenge_passed': True}),
        ('provider_failure', {**good, 'token': 'fixture-invalid'}),
        ('provider_timeout', {**good, 'token': 'fixture-timeout'}),
        ('malformed_result', {**good, 'token': 'fixture-malformed'}),
        ('string_success', {**good, 'token': 'fixture-string-success'}),
        ('wrong_hostname', {**good, 'token': 'fixture-wrong-host'}),
        ('wrong_action', {**good, 'token': 'fixture-wrong-action'}),
        ('invalid_email', {'email': 'not-an-email', 'token': 'fixture-pass-once'}),
        ('expiry_failure_fixture', {**good, 'token': 'fixture-expired'}),
        ('valid_but_unqualified', {**good, 'token': 'fixture-pass-once'}),
        ('replayed_token', {**good, 'token': 'fixture-pass-once'}),
    ]
    observations = []
    try:
        for name, payload in cases:
            connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
            try:
                connection.request('POST', '/lead', json.dumps(payload), {'Content-Type': 'application/json'})
                response = connection.getresponse()
                body = json.loads(response.read())
                observations.append({'case': name, 'http_status': response.status, 'decision': body['decision'], 'stored_leads_after': len(gate.leads), 'pending_receipts_after': len(gate.pending_receipts)})
            finally:
                connection.close()
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)
    return {'mode': 'loopback-http-with-fixture-verifier', 'provider_validation_executed': False, 'external_requests': 0, 'observations': observations, 'stored_leads': len(gate.leads), 'pending_receipts': len(gate.pending_receipts), 'qualified_leads': sum(x['qualification'] == 'qualified' for x in gate.leads), 'ad_events_sent': 0}


if __name__ == '__main__':
    print(json.dumps(run(), indent=2))

Python’s HTTP server and HTTP client provide the local transport. This small in-memory lab is a test exercise, not a production form server.

4. Check what each request actually created

The table below comes from the executed reference JSON. The “after” counts are cumulative across requests in the displayed order. A pending receipt is local work waiting to happen; it is not proof that a CRM received anything.

Local caseHTTPDecisionLeads afterReceipts after
missing_token400missing-or-invalid-token00
client_approved_only400missing-or-invalid-token00
provider_failure403verification-rejected00
provider_timeout503verification-unavailable00
malformed_result503invalid-verification-result00
string_success503invalid-verification-result00
wrong_hostname403hostname-mismatch00
wrong_action403action-mismatch00
invalid_email422invalid-email-format00
expiry_failure_fixture403verification-rejected00
valid_but_unqualified202accepted-for-review11
replayed_token403verification-rejected11

The reference ends with 1 accepted lead, 1 pending receipt, 0 qualified leads and 0 ad events sent. The reserved example email passes only the lab’s basic syntax test. The program never confirms inbox access, a service need or a sale. That is why the accepted record remains unreviewed.

Download the executed reference JSON. Compare your run with these application outcomes; do not use this fixture as a CAPTCHA accuracy benchmark or evidence about a real advertiser’s traffic.

5. Recover without changing an unknown result into success

Decide how a legitimate customer retries when verification is unavailable, the token expires or the submission response is lost. Keep the form usable, show an accurate outcome and obtain a fresh challenge when the selected provider flow requires one. Test slow submissions and a keyboard-only journey in your own test environment.

Challenge verification and application idempotency are separate. The lab rejects its consumed fixture token; it does not demonstrate a lost-response retry strategy. The storage-and-retry exercise demonstrates a stable request key and atomic local lead/job storage. A real integration must bind previously verified state to the intended server session and payload, rather than trust a browser flag or replay a spent token.

For integration tests, Cloudflare publishes dummy keys with controlled pass/fail behavior. Separate them from production configuration. A pass with a test key checks the chosen integration path, not detection quality.

Cloudflare: test keys and automated testing. No live provider request was executed for this article.

6. Count the intended business outcome

For a live investigation, select a completed cohort and retain campaign/network context, the server’s acceptance reference and the later CRM result. Separate fake details, unreachable contacts, real people outside your service area, duplicate transport and qualified business. They call for different fixes.

Check the campaign’s actual goal selection with the conversion-signal diagnostic. The local action audit below reviews supported fields in an export; it does not verify a CAPTCHA, inspect your form or certify a lead. For accepted uploads missing from reports, use the offline-import diagnostic.

Runs in this browser. Your action names and settings stay here.

Use the read-only export script below. Keep the header row. CSV and tab-separated exports with the same headers are accepted.

Check campaign goals to confirm bidding eligibility. Custom goals can include secondary actions. Match transaction or lead IDs to verify repeated events.

All four local utilities and both read-only scripts remain in the free tools hub. A browser-looking visit can also be automated: the JavaScript signal lab demonstrates that separate observation boundary.

7. Turn the finding into one acceptance check

Download the CAPTCHA and lead review worksheet. Record the first failed checkpoint, the legitimate recovery path, the owner and the result that would prove the repair. Keep actual tokens, keys and contact details out of shared reports.

Open the lead review worksheet
CAPTCHA AND LEAD ACCEPTANCE REVIEW
Use internal record references; keep tokens, secrets and contact details private.
This worksheet does not test a live provider or change account settings.

Form / owner / environment / review date:
Business outcome counted by the campaign:
Browser widget and actual server acceptance endpoint:
Provider / widget configuration / server-side verification owner:
Expected hostname inventory and configured action:
Verified production/test key separation (do not paste keys here):

CONTROLLED ACCEPTANCE CHECKS
Missing token creates no lead, receipt or conversion job:
Client-only success flag creates no accepted record:
Provider rejection creates no accepted record:
Verification timeout or malformed result follows a deliberate recovery path:
Wrong hostname/action is rejected where those checks are configured:
Provider-reported expiry/duplicate is handled without a false success:
Invalid form payload is rejected independently of challenge success:
Valid submission reaches the business once with a stable internal reference:
Lost response and retry use a server-bound, idempotent acceptance strategy:
Legitimate customer can recover and complete the intended task:

OUTCOME AND MEASUREMENT
Received record / business receipt / reachability / qualification are distinct:
Campaign, network, period and timezone of the selected cohort:
Selected action, campaign goal and duplicate delivery paths:
Qualified business outcome and supported feedback result:
Remaining unknowns / next check / owner / review date:
Proposed change / acceptance criterion / rollback:

Evaluate protection with the same business evidence

Use AdProtektor’s traffic audit for observed website visits and review configured ConversionOS handling alongside the acceptance trail. Keep website enforcement, supported ad exclusions and qualified outcomes distinguishable. A challenge or visitor label alone does not settle them.

Start a free AdProtektor trial to review observed traffic and configured protection with your business records.