> ## 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.

# Collect every vendor invoice into one sheet

> Agents sign in to each billing portal at once, bring back every invoice's number, date and amount, keep its text, and file it all as a CSV.

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 />

Month end means signing in to six billing portals, finding the invoices page in each one, and copying numbers into a spreadsheet. This program sends one agent per portal, each signed in through its own profile, a few at a time. Each returns every invoice from the month with its number, date, total and status. The program then reads each linked PDF as the same signed-in user, keeps its text beside the numbers, and writes a CSV your bookkeeper can import.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* One [profile](/docs/account/profiles) per portal, signed in and labelled `ap-<vendor>` (`ap-aws`, `ap-zoom`...). Sign each one in once: from TypeScript with `oc.browser({ profile })`, or with an agent run that types your password from a [text secret](/docs/account/secrets), reading any emailed code through an inbox [integration](/docs/account/integrations) such as AgentMail.
* Optional: `SHEET_WEBHOOK_URL`, an endpoint of yours that appends rows to your sheet or accounting system, for example a Google Apps Script web app.

## How it works

<Steps>
  <Step title="Every portal at once, inside your limit">
    Every key on your account shares one browser slot count. Browsers, agent runs, crawls, searches and scrapes that aren't served from cache each hold a slot. The program keeps `MAX_AT_ONCE` portals going, below your plan's count. A submit over the limit is refused with `ConcurrencyLimitError`, and the program waits 20 seconds and tries again; a `QuotaExceededError` for requests per minute says how long to wait in `retryAfterSeconds`.
  </Step>

  <Step title="An agent reads each billing page">
    Each [agent run](/docs/agents/overview) starts signed in through its [profile](/docs/account/profiles), finds the month's invoices, and answers a [schema](/docs/agents/overview#typed-results): number, date, total, currency, status, and the PDF link when the page shows one. The task is read-only, and the [rule](/docs/account/rules) `Only use aws.amazon.com.` is enforced for each portal's own site.
  </Step>

  <Step title="The invoice text, read as the same user">
    Once a run ends its profile is free, and a [scrape](/docs/scrape/overview) of each PDF link with that profile reads the invoice as the signed-in user. Its text is saved as `invoices/2026-08/<vendor>-<number>.md`.
  </Step>

  <Step title="Filed as a CSV">
    Every row goes into `invoices/2026-08/invoices.csv`, and, if you set `SHEET_WEBHOOK_URL`, to your own endpoint as JSON.
  </Step>
</Steps>

<Note>
  Outcrawl does not hand you the PDF file itself: [files](/docs/agents/files) go into a run, never out. What you keep is the invoice's text, read from its link. A scanned PDF with no text layer, or a link that has expired, is marked `unread` in the `stored` column; a portal that builds the PDF in the browser and shows no link leaves it empty. Download those ones yourself.
</Note>

## The program

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

  const oc = new Outcrawl();
  const MONTH = { label: 'August 2026', key: '2026-08' };
  const SHEET_WEBHOOK_URL = process.env['SHEET_WEBHOOK_URL'] ?? ''; // optional: your own endpoint that appends rows

  // Each profile is signed in to that vendor's billing portal.
  const vendors = [
    { name: 'AWS', site: 'aws.amazon.com', billing: 'https://us-east-1.console.aws.amazon.com/billing/home#/bills', profile: 'ap-aws' },
    { name: 'Google Workspace', site: 'google.com', billing: 'https://admin.google.com/ac/billing/accounts', profile: 'ap-google' },
    { name: 'Slack', site: 'slack.com', billing: 'https://acme-team.slack.com/admin/billing/history', profile: 'ap-slack' },
    { name: 'Atlassian', site: 'atlassian.com', billing: 'https://admin.atlassian.com', profile: 'ap-atlassian' },
    { name: 'Zoom', site: 'zoom.us', billing: 'https://zoom.us/billing', profile: 'ap-zoom' },
    { name: 'Twilio', site: 'twilio.com', billing: 'https://console.twilio.com/us1/billing/manage-billing/invoices', profile: 'ap-twilio' },
  ];

  // Runs at once. Keep it below your plan's browser count (Free 2, Builder 5, Pro 25, Scale 100).
  const MAX_AT_ONCE = 3;

  interface Invoice {
    number: string;
    date: string;
    total: string;
    currency: string;
    status: string;
    documentUrl?: string;
  }

  const invoiceSchema = {
    type: 'object',
    properties: {
      invoices: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            number: { type: 'string' },
            date: { type: 'string', description: 'Invoice date, YYYY-MM-DD' },
            total: { type: 'string', description: 'Total due as a plain decimal, e.g. 412.87' },
            currency: { type: 'string', description: 'ISO code, e.g. USD' },
            status: { type: 'string', description: 'paid, due or overdue' },
            documentUrl: { type: 'string', description: 'Direct link to the invoice PDF, if the page shows one' },
          },
          required: ['number', 'date', 'total', 'currency', 'status'],
        },
      },
    },
    required: ['invoices'],
  };

  // Submit a run and answer its id, waiting for a free browser slot instead of failing.
  async function start(request: AgentRequest): Promise<string> {
    for (;;) {
      try {
        return (await oc.agent(request).submitted).id;
      } catch (error) {
        if (error instanceof ConcurrencyLimitError) {
          await sleep(20_000); // no Retry-After: a slot frees when one of your runs ends
        } else if (error instanceof QuotaExceededError && error.ceiling === 'requests_per_minute') {
          await sleep(error.retryAfterSeconds * 1000); // the error says how long to wait
        } else {
          throw error; // out of monthly credits, or a bad request: retrying cannot help
        }
      }
    }
  }

  async function inPool<T, R>(items: readonly T[], size: number, work: (item: T) => Promise<R>): Promise<R[]> {
    const results: R[] = new Array<R>(items.length);
    let next = 0;
    const worker = async () => {
      while (next < items.length) {
        const index = next++;
        results[index] = await work(items[index] as T);
      }
    };
    await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
    return results;
  }

  const profiles = await oc.profiles.list({ label: 'ap-*' });
  const profileIds = new Map(profiles.map((profile) => [profile.label, profile.id]));
  await mkdir(`invoices/${MONTH.key}`, { recursive: true });

  const perVendor = await inPool(vendors, MAX_AT_ONCE, async (vendor) => {
    const profile = profileIds.get(vendor.profile);
    if (profile === undefined) return { vendor: vendor.name, invoices: [], problem: `no profile ${vendor.profile}` };

    const run = await oc.agent.job(await start({
      task:
        `You are signed in to ${vendor.name}. Open ${vendor.billing} and find every invoice dated in ${MONTH.label}. ` +
        'For each one, report its number, date, total, currency, whether it is paid, and the direct link to its PDF ' +
        'if the page shows one. Read only: do not pay, dispute or change anything.',
      caps: { budget: '150.00', duration: '10m' },
      profile,
      rules: [`Only use ${vendor.site}.`],
      schema: invoiceSchema,
    })).settled();
    const invoices = (run.data as { invoices: Invoice[] } | undefined)?.invoices ?? [];
    const problem = run.data === undefined ? `run ended ${run.stopReason ?? run.status}, replay ${run.replayUrl ?? 'none'}` : undefined;

    // The run has ended, so its profile is free: read each linked PDF as that same signed-in user.
    const rows = [];
    for (const invoice of invoices) {
      let stored = '';
      if (invoice.documentUrl !== undefined) {
        try {
          const doc = await oc.scrape(invoice.documentUrl, { profile, formats: ['markdown'] });
          stored = `invoices/${MONTH.key}/${vendor.name.replace(/\W+/g, '-')}-${invoice.number}.md`;
          await writeFile(stored, doc.markdown ?? '');
        } catch (error) {
          stored = `unread: ${(error as Error).message}`; // a scan with no text layer, a link that expired
        }
      }
      rows.push({ vendor: vendor.name, ...invoice, stored });
    }
    return { vendor: vendor.name, invoices: rows, problem };
  });

  const rows = perVendor.flatMap((entry) => entry.invoices);
  const columns = ['vendor', 'number', 'date', 'total', 'currency', 'status', 'documentUrl', 'stored'] as const;
  const csv = [
    columns.join(','),
    ...rows.map((row) => columns.map((column) => `"${String(row[column] ?? '').replaceAll('"', '""')}"`).join(',')),
  ].join('\n');
  await writeFile(`invoices/${MONTH.key}/invoices.csv`, `${csv}\n`);

  if (SHEET_WEBHOOK_URL !== '') {
    await fetch(SHEET_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ month: MONTH.key, rows }),
    });
  }

  console.table(rows.map(({ vendor, number, date, total, currency, status }) => ({ vendor, number, date, total, currency, status })));
  for (const entry of perVendor) if (entry.problem !== undefined) console.log(`${entry.vendor}: ${entry.problem}`);
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import asyncio
  import csv
  import os
  import re
  from pathlib import Path

  import httpx

  from outcrawl import ConcurrencyLimitError, Outcrawl, QuotaExceededError

  MONTH = {"label": "August 2026", "key": "2026-08"}
  SHEET_WEBHOOK_URL = os.environ.get("SHEET_WEBHOOK_URL", "")  # optional: your own endpoint that appends rows

  # Each profile is signed in to that vendor's billing portal.
  VENDORS = [
      {"name": "AWS", "site": "aws.amazon.com", "billing": "https://us-east-1.console.aws.amazon.com/billing/home#/bills", "profile": "ap-aws"},
      {"name": "Google Workspace", "site": "google.com", "billing": "https://admin.google.com/ac/billing/accounts", "profile": "ap-google"},
      {"name": "Slack", "site": "slack.com", "billing": "https://acme-team.slack.com/admin/billing/history", "profile": "ap-slack"},
      {"name": "Atlassian", "site": "atlassian.com", "billing": "https://admin.atlassian.com", "profile": "ap-atlassian"},
      {"name": "Zoom", "site": "zoom.us", "billing": "https://zoom.us/billing", "profile": "ap-zoom"},
      {"name": "Twilio", "site": "twilio.com", "billing": "https://console.twilio.com/us1/billing/manage-billing/invoices", "profile": "ap-twilio"},
  ]

  # Runs at once. Keep it below your plan's browser count (Free 2, Builder 5, Pro 25, Scale 100).
  MAX_AT_ONCE = 3

  INVOICE_SCHEMA = {
      "type": "object",
      "properties": {
          "invoices": {
              "type": "array",
              "items": {
                  "type": "object",
                  "properties": {
                      "number": {"type": "string"},
                      "date": {"type": "string", "description": "Invoice date, YYYY-MM-DD"},
                      "total": {"type": "string", "description": "Total due as a plain decimal, e.g. 412.87"},
                      "currency": {"type": "string", "description": "ISO code, e.g. USD"},
                      "status": {"type": "string", "description": "paid, due or overdue"},
                      "documentUrl": {"type": "string", "description": "Direct link to the invoice PDF, if the page shows one"},
                  },
                  "required": ["number", "date", "total", "currency", "status"],
              },
          }
      },
      "required": ["invoices"],
  }
  COLUMNS = ["vendor", "number", "date", "total", "currency", "status", "documentUrl", "stored"]


  async def start(oc: Outcrawl, **request) -> str:
      """Submit a run and answer its id, waiting for a free browser slot instead of failing."""
      while True:
          try:
              return (await oc.agent(**request).submitted)["id"]
          except ConcurrencyLimitError:
              await asyncio.sleep(20)  # no Retry-After: a slot frees when one of your runs ends
          except QuotaExceededError as error:
              if error.ceiling != "requests_per_minute":
                  raise  # out of monthly credits: retrying cannot help
              await asyncio.sleep(error.retry_after_seconds)  # the error says how long to wait


  async def main() -> None:
      folder = Path("invoices") / MONTH["key"]
      folder.mkdir(parents=True, exist_ok=True)
      async with Outcrawl() as oc:
          profiles = await oc.profiles.list(label="ap-*")
          profile_ids = {profile["label"]: profile["id"] for profile in profiles}
          slots = asyncio.Semaphore(MAX_AT_ONCE)

          async def collect(vendor: dict) -> dict:
              profile = profile_ids.get(vendor["profile"])
              if profile is None:
                  return {"vendor": vendor["name"], "rows": [], "problem": f"no profile {vendor['profile']}"}
              async with slots:
                  run_id = await start(
                      oc,
                      task=(
                          f"You are signed in to {vendor['name']}. Open {vendor['billing']} and find every invoice "
                          f"dated in {MONTH['label']}. For each one, report its number, date, total, currency, whether "
                          "it is paid, and the direct link to its PDF if the page shows one. Read only: do not pay, "
                          "dispute or change anything."
                      ),
                      caps={"budget": "150.00", "duration": "10m"},
                      profile=profile,
                      rules=[f"Only use {vendor['site']}."],
                      schema=INVOICE_SCHEMA,
                  )
                  run = await oc.agent.job(run_id).settled()
                  data = run.get("data")
                  problem = None if data else f"run ended {run.get('stopReason') or run['status']}, replay {run.get('replayUrl')}"

                  # The run has ended, so its profile is free: read each linked PDF as that same signed-in user.
                  rows = []
                  for invoice in (data or {}).get("invoices", []):
                      stored = ""
                      if invoice.get("documentUrl"):
                          try:
                              doc = await oc.scrape(invoice["documentUrl"], profile=profile, formats=["markdown"])
                              slug = re.sub(r"\W+", "-", vendor["name"])
                              target = folder / f"{slug}-{invoice['number']}.md"
                              target.write_text(doc.get("markdown") or "")
                              stored = str(target)
                          except Exception as error:
                              stored = f"unread: {error}"  # a scan with no text layer, a link that expired
                      rows.append({"vendor": vendor["name"], **invoice, "stored": stored})
              return {"vendor": vendor["name"], "rows": rows, "problem": problem}

          per_vendor = await asyncio.gather(*(collect(vendor) for vendor in VENDORS))

      rows = [row for entry in per_vendor for row in entry["rows"]]
      with open(folder / "invoices.csv", "w", newline="") as out:
          writer = csv.DictWriter(out, fieldnames=COLUMNS, extrasaction="ignore")
          writer.writeheader()
          writer.writerows(rows)

      if SHEET_WEBHOOK_URL:
          async with httpx.AsyncClient() as http:
              await http.post(SHEET_WEBHOOK_URL, json={"month": MONTH["key"], "rows": rows})

      for row in rows:
          print(f"{row['vendor']:<18} {row['number']:<22} {row['date']}  {row['total']:>10} {row['currency']}  {row['status']}")
      for entry in per_vendor:
          if entry["problem"]:
              print(f"{entry['vendor']}: {entry['problem']}")


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

## What you get back

`invoices/2026-08/invoices.csv`:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
vendor,number,date,total,currency,status,documentUrl,stored
"AWS","EUINGB26-1182443","2026-08-02","1843.27","USD","paid","https://us-east-1.console.aws.amazon.com/billing/home#/bills?invoice=EUINGB26-1182443","invoices/2026-08/AWS-EUINGB26-1182443.md"
"Google Workspace","5261834903","2026-08-01","432.00","USD","paid","https://payments.google.com/payments/apis-secure/doc/5261834903.pdf","invoices/2026-08/Google-Workspace-5261834903.md"
"Slack","SLK-0823-77120","2026-08-15","1275.00","USD","paid","https://acme-team.slack.com/admin/billing/invoice/SLK-0823-77120.pdf","invoices/2026-08/Slack-SLK-0823-77120.md"
"Atlassian","IN-004-918-223","2026-08-09","1512.50","USD","due","",""
"Zoom","INV214773905","2026-08-12","299.80","USD","paid","https://zoom.us/billing/invoice/INV214773905.pdf","unread: could not represent https://zoom.us/billing/invoice/INV214773905.pdf as text: it is a scan: the pages are images and carry no text layer to read. ..."
"Twilio","TW-AUG26-00419","2026-08-31","86.42","USD","paid","https://console.twilio.com/us1/billing/invoices/TW-AUG26-00419.pdf","invoices/2026-08/Twilio-TW-AUG26-00419.md"
```

Here Atlassian showed no PDF link, and Zoom's PDF was a scan: download those two yourself. A portal whose run did not finish is printed at the end with its replay link, so you can watch where it stopped.

## What it costs

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

* **Each portal** takes about 2 minutes: **about 30 credits**.
* **Each invoice PDF** is about 1 credit.

Six portals and six invoices: 6 × 30 + 6 = **about 186 credits a month**, under a fifth of the Free plan's 1,000. Each portal run's `caps.budget` is 150.

## Make it yours

* File straight into your accounting: replace the sheet POST with one call per row to your accounting system's API to create a bill.
* Run it on the 3rd of every month from your own scheduler (cron, a CI schedule) and set `MONTH` from the date.
* Catch what is unpaid: post every row whose `status` is `due` or `overdue` to Slack.
* Let a run sign back in when a session has lapsed: give it your password as a [secret](/docs/account/secrets) and an inbox [integration](/docs/account/integrations) for the code.

## Related

<CardGroup cols={2}>
  <Card title="Profiles" icon="id" href="/docs/account/profiles">One signed-in browser per portal.</Card>
  <Card title="Agents" icon="player-play" href="/docs/agents/overview">Runs, caps and typed results.</Card>
  <Card title="Scrape" icon="file-text" href="/docs/scrape/overview">Read a page or a PDF as text.</Card>
  <Card title="Files" icon="paperclip" href="/docs/agents/files">What goes into a run, and why nothing comes out.</Card>
</CardGroup>


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