> ## Documentation Index
> Fetch the complete documentation index at: https://outcrawl.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Sign up to your own product every hour, from three countries

> Agents run your real signup flow, email verification included, and file a GitHub issue with the replay when a step breaks.

export const OutcrawlStyles = () => {
  if (typeof document !== 'undefined' && !document.getElementById('outcrawl-overrides')) {
    const s = document.createElement('style');
    s.id = 'outcrawl-overrides';
    s.textContent = `
      /* Match the product's radius grid (4 / 6 / 8 / 9999), not Mintlify's default. */
      .rounded-sm { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-md { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-lg { border-radius: 0.5rem !important; }   /* 8px, --r-panel */
      .rounded-l-sm { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-sm { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-md { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-md { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-lg { border-top-left-radius: 0.5rem !important; border-bottom-left-radius: 0.5rem !important; }
      .rounded-r-lg { border-top-right-radius: 0.5rem !important; border-bottom-right-radius: 0.5rem !important; }

      /* Cards: never pure white in light mode, matching the one-big-card surface tone. */
      .card { background-color: #fcfcfc !important; border-radius: 0.5rem !important; }
      html.dark .card { background-color: #141311 !important; }

      /* Docs hero box */
      .oc-hero { background-color: #fcfcfc; border: 1px solid #e5e2dc; }
      html.dark .oc-hero { background-color: #141311; border-color: #26241f; }
      html.dark .oc-hero h1 { color: #edeae4; }

      /* Thin, transparent-track, hide-until-hover scrollbar. */
      ::-webkit-scrollbar { width: 6px; height: 6px; background-color: transparent; }
      ::-webkit-scrollbar-track { background-color: transparent; }
      ::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.5); border-radius: 10px; transition: opacity 0.3s ease; opacity: 0; }
      ::-webkit-scrollbar-thumb:hover { background-color: rgba(155, 155, 155, 0.7); }
      *:hover::-webkit-scrollbar-thumb,
      *:focus::-webkit-scrollbar-thumb,
      *:active::-webkit-scrollbar-thumb { opacity: 1; }
      * { scrollbar-width: thin; scrollbar-color: rgba(155, 155, 155, 0.5) transparent; }
    `;
    document.head.appendChild(s);
  }
  return null;
};

<OutcrawlStyles />

Signup breaks quietly: a verification email that stops arriving, a form that rejects a Brazilian phone number, a consent banner in Germany that covers the submit button. Unit tests do not catch it and nobody on the team signs up to their own product every hour. This program does, from three countries at once, with a fresh inbox each time, and files one GitHub issue per broken step with the replay of the run attached.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* An inbox connected as the `agentmail` [integration](/docs/account/integrations), so the agent can create a throwaway address and read the verification email. Any inbox service with an MCP server works; change the name in the code.
* A GitHub token that can write issues, in `GITHUB_TOKEN`, and your repository as `owner/name` in `GITHUB_REPO`.
* Something that runs a command every hour: cron, a GitHub Actions schedule (shown below), or your own job runner. Outcrawl schedules [monitors](/docs/monitors/overview), which watch a URL; a task like this one is started by your code.

## How it works

<Steps>
  <Step title="Your scheduler starts the program">
    Once an hour the program submits one [agent run](/docs/agents/overview) per country. Two run at a time, below your plan's browser count, and a run refused with `ConcurrencyLimitError` waits 20 seconds for a slot and tries again.
  </Step>

  <Step title="Each run signs up from a different country">
    `exit: { country }` sends the run through a residential exit in that country, so it sees the language, currency, consent banners and phone formats a local customer sees.
  </Step>

  <Step title="The agent creates an inbox and verifies the address">
    The run may call the `agentmail` [integration](/docs/account/integrations) because it is named in `integrations`. It creates a new inbox, signs up with it, waits for the verification email and opens the link. [Rules](/docs/account/rules) keep it on your domain and stop it paying for anything.
  </Step>

  <Step title="The result comes back as data">
    A [schema](/docs/agents/overview#typed-results) makes every run answer the same shape: each step, pass or fail, how long it took, and the exact error text the page showed.
  </Step>

  <Step title="Failures become issues, successes stay quiet">
    A failed step files a GitHub issue with the run's [replay](/docs/browsers/sessions) link. The same step failing again from the same country adds a comment to that issue instead of opening another.
  </Step>
</Steps>

## The program

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import { setTimeout as sleep } from 'node:timers/promises';
  import { ConcurrencyLimitError, Outcrawl, QuotaExceededError } from '@outcrawl/sdk';

  const oc = new Outcrawl(); // reads OUTCRAWL_API_KEY

  const SIGNUP_URL = 'https://app.yourproduct.com/signup';
  const COUNTRIES = ['us', 'de', 'br'];
  const PARALLEL = 2; // below your plan's browser count, so other work still gets a slot
  const REPO = process.env.GITHUB_REPO ?? 'your-org/your-app';
  const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? '';

  const STEPS = ['open-signup', 'submit-form', 'verification-email', 'verify-link', 'onboarding', 'dashboard'];

  interface SignupCheck {
    reachedDashboard: boolean;
    email: string;
    failedStep: string; // one of STEPS, or 'none'
    pageError: string; // the error text the page showed, '' when none
    steps: { name: string; ok: boolean; seconds: number; note: string }[];
  }

  const schema = {
    type: 'object',
    properties: {
      reachedDashboard: { type: 'boolean' },
      email: { type: 'string', description: 'The address you signed up with.' },
      failedStep: { type: 'string', enum: [...STEPS, 'none'] },
      pageError: { type: 'string', description: "The exact error text on the page, or '' when none." },
      steps: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            name: { type: 'string', enum: STEPS },
            ok: { type: 'boolean' },
            seconds: { type: 'number' },
            note: { type: 'string' },
          },
          required: ['name', 'ok', 'seconds', 'note'],
        },
      },
    },
    required: ['reachedDashboard', 'email', 'failedStep', 'pageError', 'steps'],
  };

  const task = `Test the signup flow of our product as a brand new customer would.
  open-signup: create a new inbox with the agentmail tools, then open ${SIGNUP_URL}.
  submit-form: sign up with that inbox address, the name "Signup Check" and a strong password you make up.
  verification-email: wait up to 3 minutes for the verification email in that inbox.
  verify-link: open the link in that email.
  onboarding: finish onboarding with the first option offered at every choice.
  dashboard: stop when the product dashboard is showing.
  Record each step with how long it took. At the first step that fails, stop, record the exact error text on the page, and set failedStep to that step.`;

  async function checkFrom(country: string) {
    for (;;) {
      const job = oc.agent({
        task,
        schema,
        caps: { budget: '100.00', duration: '6m' },
        exit: { country }, // a residential exit in that country
        integrations: ['agentmail'],
        rules: ['Only use yourproduct.com.', 'Never make a payment.'],
      });
      try {
        const { watchUrl } = await job.submitted;
        const run = await job;
        return { country, run, replay: run.replayUrl ?? watchUrl ?? '(no replay)' };
      } catch (err) {
        // Over your browser count: refused, not queued. A slot frees when one of your runs ends.
        if (err instanceof ConcurrencyLimitError) await sleep(20_000);
        // Over requests per minute: wait as long as the error says. Out of monthly credits: stop.
        else if (err instanceof QuotaExceededError && err.ceiling === 'requests_per_minute') await sleep(err.retryAfterSeconds * 1000);
        else throw err;
      }
    }
  }

  // Run `work` over every item, never more than `limit` at once.
  async function inLanes<T, R>(items: T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
    const out: R[] = [];
    let next = 0;
    const lane = async () => {
      while (next < items.length) {
        const i = next++;
        out[i] = await work(items[i]!);
      }
    };
    await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
    return out;
  }

  async function github(path: string, method = 'GET', body?: unknown): Promise<unknown> {
    const res = await fetch(`https://api.github.com/repos/${REPO}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${GITHUB_TOKEN}`,
        Accept: 'application/vnd.github+json',
        'Content-Type': 'application/json',
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    if (!res.ok) throw new Error(`GitHub ${res.status}: ${await res.text()}`);
    return res.json();
  }

  const results = await inLanes(COUNTRIES, PARALLEL, checkFrom);
  let failures = 0;

  for (const { country, run, replay } of results) {
    const check = run.data as SignupCheck | undefined;
    if (run.status === 'completed' && check?.reachedDashboard === true) {
      console.log(`${country}: ok in ${Math.round(run.durationMs / 1000)} s, ${run.credits} credits`);
      continue; // success stays quiet
    }
    failures++;
    const step = check?.failedStep && check.failedStep !== 'none' ? check.failedStep : `run ${run.stopReason ?? run.status}`;
    const title = `Signup fails from ${country.toUpperCase()} at ${step}`;
    const body = [
      `**Failed step:** \`${step}\``,
      `**Country:** ${country.toUpperCase()}`,
      `**Page said:** ${check?.pageError ? `"${check.pageError}"` : 'nothing'}`,
      `**Replay:** ${replay}`,
      `**Run:** \`${run.id}\`, ${run.status}, ${run.credits} credits, ${new Date().toISOString()}`,
      '',
      '| Step | Result | Seconds | Note |',
      '| --- | --- | --- | --- |',
      ...(check?.steps ?? []).map((s) => `| ${s.name} | ${s.ok ? 'pass' : 'FAIL'} | ${s.seconds} | ${s.note} |`),
    ].join('\n');

    // One open issue per country and step: a repeat failure adds a comment, not a new issue.
    const open = (await github('/issues?labels=signup-qa&state=open&per_page=100')) as { number: number; title: string }[];
    const known = open.find((issue) => issue.title === title);
    if (known) await github(`/issues/${known.number}/comments`, 'POST', { body });
    else await github('/issues', 'POST', { title, body, labels: ['signup-qa'] });
    console.log(`${country}: FAILED at ${step}, ${known ? `commented on #${known.number}` : 'filed an issue'}`);
  }

  process.exitCode = failures > 0 ? 1 : 0;
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import asyncio
  import os
  from datetime import datetime, timezone

  import httpx
  from outcrawl import ConcurrencyLimitError, Outcrawl, QuotaExceededError

  SIGNUP_URL = "https://app.yourproduct.com/signup"
  COUNTRIES = ["us", "de", "br"]
  PARALLEL = 2  # below your plan's browser count, so other work still gets a slot
  REPO = os.environ.get("GITHUB_REPO", "your-org/your-app")
  GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")

  STEPS = ["open-signup", "submit-form", "verification-email", "verify-link", "onboarding", "dashboard"]

  SCHEMA = {
      "type": "object",
      "properties": {
          "reachedDashboard": {"type": "boolean"},
          "email": {"type": "string", "description": "The address you signed up with."},
          "failedStep": {"type": "string", "enum": [*STEPS, "none"]},
          "pageError": {"type": "string", "description": "The exact error text on the page, or '' when none."},
          "steps": {
              "type": "array",
              "items": {
                  "type": "object",
                  "properties": {
                      "name": {"type": "string", "enum": STEPS},
                      "ok": {"type": "boolean"},
                      "seconds": {"type": "number"},
                      "note": {"type": "string"},
                  },
                  "required": ["name", "ok", "seconds", "note"],
              },
          },
      },
      "required": ["reachedDashboard", "email", "failedStep", "pageError", "steps"],
  }

  TASK = f"""Test the signup flow of our product as a brand new customer would.
  open-signup: create a new inbox with the agentmail tools, then open {SIGNUP_URL}.
  submit-form: sign up with that inbox address, the name "Signup Check" and a strong password you make up.
  verification-email: wait up to 3 minutes for the verification email in that inbox.
  verify-link: open the link in that email.
  onboarding: finish onboarding with the first option offered at every choice.
  dashboard: stop when the product dashboard is showing.
  Record each step with how long it took. At the first step that fails, stop, record the exact error text on the page, and set failedStep to that step."""


  async def check_from(oc: Outcrawl, lanes: asyncio.Semaphore, country: str) -> tuple[str, dict, str]:
      async with lanes:  # never more than PARALLEL runs at once
          while True:
              job = oc.agent(
                  task=TASK,
                  schema=SCHEMA,
                  caps={"budget": "100.00", "duration": "6m"},
                  exit={"country": country},  # a residential exit in that country
                  integrations=["agentmail"],
                  rules=["Only use yourproduct.com.", "Never make a payment."],
              )
              try:
                  submitted = await job.submitted
                  run = await job
                  return country, run, run.get("replayUrl") or submitted.get("watchUrl") or "(no replay)"
              except ConcurrencyLimitError:
                  # Over your browser count: refused, not queued. A slot frees when one of your runs ends.
                  await asyncio.sleep(20)
              except QuotaExceededError as err:
                  # Over requests per minute: wait as long as the error says. Out of monthly credits: stop.
                  if err.ceiling != "requests_per_minute":
                      raise
                  await asyncio.sleep(err.retry_after_seconds)


  async def report(gh: httpx.AsyncClient, country: str, run: dict, replay: str) -> str:
      check = run.get("data") or {}
      failed = check.get("failedStep")
      step = failed if failed and failed != "none" else f"run {run.get('stopReason') or run['status']}"
      title = f"Signup fails from {country.upper()} at {step}"
      page_said = f'"{check["pageError"]}"' if check.get("pageError") else "nothing"
      rows = [f"| {s['name']} | {'pass' if s['ok'] else 'FAIL'} | {s['seconds']} | {s['note']} |" for s in check.get("steps", [])]
      body = "\n".join([
          f"**Failed step:** `{step}`",
          f"**Country:** {country.upper()}",
          f"**Page said:** {page_said}",
          f"**Replay:** {replay}",
          f"**Run:** `{run['id']}`, {run['status']}, {run['credits']} credits, {datetime.now(timezone.utc).isoformat()}",
          "",
          "| Step | Result | Seconds | Note |",
          "| --- | --- | --- | --- |",
          *rows,
      ])

      # One open issue per country and step: a repeat failure adds a comment, not a new issue.
      listed = await gh.get("/issues", params={"labels": "signup-qa", "state": "open", "per_page": 100})
      known = next((i for i in listed.raise_for_status().json() if i["title"] == title), None)
      if known:
          (await gh.post(f"/issues/{known['number']}/comments", json={"body": body})).raise_for_status()
          return f"FAILED at {step}, commented on #{known['number']}"
      (await gh.post("/issues", json={"title": title, "body": body, "labels": ["signup-qa"]})).raise_for_status()
      return f"FAILED at {step}, filed an issue"


  async def main() -> int:
      github = httpx.AsyncClient(
          base_url=f"https://api.github.com/repos/{REPO}",
          headers={"Authorization": f"Bearer {GITHUB_TOKEN}", "Accept": "application/vnd.github+json"},
      )
      async with Outcrawl() as oc, github as gh:  # Outcrawl() reads OUTCRAWL_API_KEY
          lanes = asyncio.Semaphore(PARALLEL)
          results = await asyncio.gather(*(check_from(oc, lanes, c) for c in COUNTRIES))

          failures = 0
          for country, run, replay in results:
              check = run.get("data") or {}
              if run["status"] == "completed" and check.get("reachedDashboard") is True:
                  print(f"{country}: ok in {round(run['durationMs'] / 1000)} s, {run['credits']} credits")
                  continue  # success stays quiet
              failures += 1
              print(f"{country}: {await report(gh, country, run, replay)}")
          return 1 if failures else 0


  raise SystemExit(asyncio.run(main()))
  ```
</CodeGroup>

The step names are an `enum` in the schema, so an issue title like `Signup fails from DE at verification-email` is identical from one hour to the next and the dedupe by title holds.

`Only use yourproduct.com.` is an enforced rule: it blocks navigation to any other host, subdomains of yourproduct.com excepted. If your email provider wraps links in a click-tracking domain, name it in the same rule, for example `Only use yourproduct.com and links.mailprovider.example.`, or the run fails at `verify-link`.

### Run it every hour

```yaml .github/workflows/signup-qa.yml theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
name: signup-qa
on:
  schedule:
    - cron: '0 * * * *'
permissions:
  contents: read
  issues: write
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install @outcrawl/sdk tsx && npx tsx signup-qa.mts
        env:
          OUTCRAWL_API_KEY: ${{ secrets.OUTCRAWL_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPO: ${{ github.repository }}
```

Save the TypeScript program as `signup-qa.mts` so it runs as a module with top-level `await`. It exits `1` when any country failed, so the workflow run turns red as well.

## What you get back

A quiet hour prints three lines and files nothing:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
us: ok in 64 s, 16.50 credits
de: ok in 71 s, 18.25 credits
br: ok in 69 s, 17.50 credits
```

An hour where German signups stopped receiving the verification email prints `de: FAILED at verification-email, filed an issue` and opens this issue:

```markdown theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
Signup fails from DE at verification-email

**Failed step:** `verification-email`
**Country:** DE
**Page said:** nothing
**Replay:** https://outcrawl.ai/play/ses_7Hq2cW
**Run:** `run_01JAX4T9`, partial, 17.75 credits, 2026-09-25T14:00:41.512Z

| Step | Result | Seconds | Note |
| --- | --- | --- | --- |
| open-signup | pass | 4 | Signup page loaded in German, cookie banner accepted |
| submit-form | pass | 11 | Account created, page asked to confirm the address |
| verification-email | FAIL | 180 | No email in the inbox after 3 minutes |
```

The replay shows the whole run, so whoever picks up the issue watches the failure instead of reproducing it.

## What it costs

An agent run is billed for the model's usage, browser time and proxy usage, per [pricing](/docs/billing/pricing). A typical run costs about 15 credits a minute all in, including the data through the residential exit. That is an estimate: the real figure depends on the task, and a heavy signup page costs more.

* A passing signup takes about 75 s, so **about 19 credits per country**.
* Three countries every hour: about 57 credits an hour, 1,368 a day, **about 41,040 a month**. That fits the Pro plan's 50,000.
* A run that waits the full 3 minutes for an email that never comes adds about 3 credits of browser time, plus the model's usage each time it checks the inbox. Each run's `caps.budget` is 100.

Run it every six hours instead of every hour and the same three countries cost about 6,840 credits a month, inside the Builder plan.

## Make it yours

* Test the paths people pay for: sign up, pick the paid plan, and stop at the card form. `Never make a payment.` is an enforced rule, so the run cannot submit it.
* Add countries by adding codes to `COUNTRIES`. Raise `PARALLEL` only while it stays below your plan's browser count.
* Delete the test accounts: have your backend remove any account created from your inbox provider's domain after an hour.
* Post the failure to Slack as well as GitHub with one more `fetch` to an incoming webhook.

## Related

<CardGroup cols={2}>
  <Card title="Agents" icon="player-play" href="/docs/agents/overview">Tasks, caps, schemas and exits.</Card>
  <Card title="Integrations" icon="plug" href="/docs/account/integrations">Connect the inbox the agent reads.</Card>
  <Card title="Rules" icon="shield-check" href="/docs/account/rules">Which rules are enforced and which are guidance.</Card>
  <Card title="Sessions and replay" icon="movie" href="/docs/browsers/sessions">The replay link in every issue.</Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.