AX/T/06 — AX/TUTORIALS
Published: May 12, 2026 · 13 min read

How to build a bot for filling web forms

Auto-fill from CRM, multi-step forms, file uploads, hidden validation. For registrations, applications, submissions, e-Government.

BeginnerPlaywrightTypeScriptCSV parsingPostgreSQL (status tracking)

Form filling is one of the simplest but most repeated automation use cases. Classic scenarios:

  • Submitting the same offer to 30 portals (jobs, real estate, marketplaces)
  • Bulk lead intake to CRM (competitor's form)
  • Registering accounts on N platforms (warming, testing)
  • Bulk applications (visa forms, gov)
  • Filling structured forms from CSV (e.g. hundreds of entries to a gov database)

We show an idempotent pipeline with resumption, error handling, screenshot evidence per submission.

What you need
  • Node.js 20+ or Python 3.11+
  • Basic JS/TS or Python knowledge
  • CSV / JSON data source to fill
  • Datacenter proxy if target has no anti-bot
Steps
  1. 01

    Declarative form descriptor

    Each form has its own structure — describe declaratively:

    // forms/lead-intake.ts
    export const leadIntakeForm = {
      url: 'https://competitor.com/contact',
      steps: [
        { field: 'input[name="email"]', value: (data) => data.email, type: 'fill' },
        { field: 'input[name="firstName"]', value: (data) => data.firstName, type: 'fill' },
        { field: 'select[name="industry"]', value: (data) => data.industry, type: 'select' },
        { field: 'textarea[name="message"]', value: (data) => data.message, type: 'fill' },
        { field: 'input[name="consent"]', type: 'check' },
        { field: 'button[type="submit"]', type: 'click' },
      ],
      successIndicator: 'text=Thank you',
      errorIndicators: ['text=Please fill', '.field-error'],
    };
  2. 02

    Form runner — fill, validate, submit

    Generic runner that executes the form descriptor:

    async function runForm(formDesc, data) {
      const browser = await chromium.launch({ headless: true });
      const context = await browser.newContext();
      const page = await context.newPage();
      
      try {
        await page.goto(formDesc.url, { waitUntil: 'networkidle' });
        
        for (const step of formDesc.steps) {
          const locator = page.locator(step.field);
          await locator.waitFor({ state: 'visible', timeout: 10000 });
          
          switch (step.type) {
            case 'fill':
              await locator.fill(step.value(data));
              break;
            case 'select':
              await locator.selectOption(step.value(data));
              break;
            case 'check':
              await locator.check();
              break;
            case 'click':
              await locator.click();
              break;
            case 'upload':
              await locator.setInputFiles(step.value(data));
              break;
          }
          
          await sleep(rand(300, 800)); // human-like timing
        }
        
        // Wait for outcome
        const success = page.locator(formDesc.successIndicator);
        const error = page.locator(formDesc.errorIndicators.join(', '));
        await Promise.race([
          success.waitFor({ timeout: 30000 }),
          error.waitFor({ timeout: 30000 }),
        ]);
        
        const isSuccess = await success.isVisible();
        const screenshot = await page.screenshot({ fullPage: true });
        
        return { success: isSuccess, screenshot };
      } finally {
        await browser.close();
      }
    }
  3. 03

    Idempotency and resumption

    1000 forms to submit. After 300 the process dies. Without idempotency: you start from zero, 300 duplicates. With idempotency: you continue from 301.

    CREATE TABLE form_submissions (
      id BIGSERIAL PRIMARY KEY,
      external_id TEXT UNIQUE NOT NULL,  -- unique per data row
      form_id TEXT NOT NULL,
      status TEXT NOT NULL CHECK (status IN ('pending', 'submitted', 'failed')),
      data JSONB NOT NULL,
      screenshot_path TEXT,
      attempts INT NOT NULL DEFAULT 0,
      last_error TEXT,
      submitted_at TIMESTAMPTZ
    );
    
    CREATE INDEX idx_form_status ON form_submissions (form_id, status);
    async function processBatch(formDesc, dataRows) {
      for (const data of dataRows) {
        const externalId = generateId(data); // e.g. email + timestamp
        
        // Skip if already submitted
        const existing = await db.query(
          'SELECT status FROM form_submissions WHERE external_id=$1',
          [externalId]
        );
        if (existing.rows.length && existing.rows[0].status === 'submitted') {
          continue; // idempotent
        }
        
        // Insert pending
        await db.query(`
          INSERT INTO form_submissions (external_id, form_id, status, data)
          VALUES ($1, $2, 'pending', $3)
          ON CONFLICT (external_id) DO UPDATE SET attempts = form_submissions.attempts + 1
        `, [externalId, formDesc.id, data]);
        
        try {
          const result = await runForm(formDesc, data);
          await db.query(`
            UPDATE form_submissions SET status=$1, screenshot_path=$2, submitted_at=NOW()
            WHERE external_id=$3
          `, [result.success ? 'submitted' : 'failed', result.screenshotPath, externalId]);
        } catch (err) {
          await db.query(`
            UPDATE form_submissions SET status='failed', last_error=$1
            WHERE external_id=$2
          `, [err.message, externalId]);
        }
      }
    }
  4. 04

    Multi-step forms and file uploads

    Wizard forms (step 1 → step 2 → review → submit). Each step has a "Next" button. File uploads via setInputFiles:

    // Multi-step descriptor
    const visaApplicationForm = {
      url: 'https://gov.example.com/visa-application',
      steps: [
        // Step 1: Personal info
        { field: 'input[name="firstName"]', type: 'fill', value: d => d.firstName },
        { field: 'input[name="lastName"]', type: 'fill', value: d => d.lastName },
        { field: 'input[name="dateOfBirth"]', type: 'fill', value: d => d.dob },
        { field: 'button:has-text("Next")', type: 'click', waitAfter: 2000 },
        
        // Step 2: Documents (file upload)
        { field: 'input[name="passport-scan"]', type: 'upload', value: d => d.passportPath },
        { field: 'input[name="photo"]', type: 'upload', value: d => d.photoPath },
        { field: 'button:has-text("Next")', type: 'click', waitAfter: 2000 },
        
        // Step 3: Review and submit
        { field: 'input[name="confirm"]', type: 'check' },
        { field: 'button[type="submit"]', type: 'click' },
      ],
      successIndicator: 'text=Application submitted',
      errorIndicators: ['.error', 'text=Invalid'],
    };
What it costs to run

Run cost for 1000 form submissions/month:

  • VPS (Hetzner CX22): €5/month
  • Storage (screenshots, DB ~5GB/month): $5-10/month
  • Proxy (datacenter, if needed — most forms have no anti-bot): $0-15/month

Total: ~$10-25/month. Cheapest use-case in automation.

Common pitfalls
  • No waitFor after each step — bot types too fast, form thinks you did not type (debounced validation). Sleep 300-800ms between fields.
  • Dynamic selectors — when form uses input_123 generated IDs, selector breaks after deploy. Use aria-label, name, or placeholder.
  • No per-step error handling — if validation rejects field 3 of 8, bot moves on and submit fails. Check error indicators after each step.
  • Cookie/consent popups — before first form interaction. Auto-dismiss via "[data-action='accept']" click.
  • No proof of submission — without screenshot and confirmation ID you do not know if it succeeded. Always save evidence.
Build yourself or hire?

Form automation = entry-level. Most forms (e.g. public portals, gov, simple B2B) you handle in 1-2 days.

When it gets hard: forms with heavy JavaScript validation, multi-step with dynamic field generation (e.g. visa applications, KYC flows), reCAPTCHA, signing via biometrics/SMS. There we take over.

Frequently asked questions
Can I submit 1000 forms simultaneously?
No. Concurrent submissions look like DDoS and most forms have rate limiting. Standard: sequential with 2-8s delay between submissions, max 3 concurrent for forms with high traffic. 1000 forms submit in 2-5h, not 5 min.
What about reCAPTCHA / Cloudflare Turnstile?
Three options: 1) paid solver (2Captcha, Anti-Captcha ~$2/1000 captchas), 2) Capsolver (AI-based, better accuracy), 3) human escape — flow pauses, sends screenshot to Slack, human solves, bot continues. Best: AVOID forms with captcha if possible.
Is submitting government forms (visa, KYC) legal?
Jurisdiction-specific. UK Home Office explicitly forbids visa form automation (ToS violation). US USCIS tolerates when applicant is a real human authorizing each submit. Polish KSeF/CEIDG have official APIs — use API instead of scraping. Consult a lawyer.
What if a form has dynamic field IDs that change?
Do not use ID selectors. Better: aria-label, name attribute, placeholder text, or text-based selectors ("button:has-text(Next)"). Resilient selectors survive frontend redeploys; ID-based ones break with every build.