First ask the supplier for a feed: an export, an API, an SFTP drop or invoices by email. Any of those beats automating their website. If they have none, automate the portal in a browser: log in once and reuse that session, handle the 2FA code, run it on a schedule, save each file to your storage, and alert when a run fails. Check the portal's terms of use and your contract first; some forbid automated access.
We make Browser Use, option 3 below. Facts about Playwright, Selenium, UiPath, Power Automate and GitHub Actions come from their own docs and pricing pages, checked October 26, 2026, and linked where used.
Three ways to automate the portal
| Option | Setup | Breaks when the site changes? | 2FA | Scheduling | Downloads | Cost |
|---|---|---|---|---|---|---|
| 1. Playwright (or Selenium) script + cron | You write the login and download steps in code | Yes. A changed selector fails the run until you fix it | A saved session skips it while valid: storageState or a persistent context. TOTP needs your own code; the auth guide has none | Yours: cron, GitHub Actions, Task Scheduler | Wait for the download event, then saveAs | Free, Apache-2.0, plus the machine it runs on |
| 2. RPA: UiPath, Power Automate | Build a flow in the vendor's designer, set up a robot machine | Yes, when its selectors stop matching. Power Automate tries fallback selectors; UiPath Enterprise lists self-healing UI automation | UiPath documents a TOTP code sample | UiPath time triggers; Power Automate: a Recurrence cloud flow triggers the desktop flow | On the machine running the flow; Power Automate's files and folders actions move them | UiPath: free Community; Basic from $25/month; unattended RPA is an add-on. Power Automate: $150 per bot per month, billed yearly, for unattended runs |
| 3. Browser agent with a saved profile (Browser Use Cloud, ours) | A plain-English task, a saved profile, and a short script to fetch the files; 1Password if the login can expire | It reads the page each run. If you ask it to, it can repair a saved script. Check its output | Profile cookies skip it while valid, or a person enters it in the live view (docs); a 1Password one-time-password field types the TOTP code (docs) | No schedule endpoint in the public V4 API. Call it from cron or GitHub Actions | Session downloads API, links valid 15 minutes | Model tokens + 20% every run, plus $0.02 per browser-hour and traffic (pricing) |
Which one:
- Playwright if the portal rarely changes and you have a developer. It costs nothing per run and does the same thing every time. For one stable portal it is the better fit than ours.
- RPA if your company already runs UiPath or Power Automate, or the same job also touches Windows desktop apps.
- A browser agent if you have many portals, layouts change, or nobody wants to maintain selectors. You pay for model tokens on every run and its output needs checking.
RPA limits worth knowing:
- Power Automate's unattended runs need a Process license or pay-as-you-go; one licensed bot runs one desktop flow at a time (Microsoft).
- UiPath's serverless cloud robots stop each job at 15 minutes (UiPath).
Option 1 in code: Playwright
First run: LOGIN=1 node invoices.mjs, log in in the window, press Resume. Later runs reuse the ./portal-profile folder and run headless. Selenium works the same way with Chrome's --user-data-dir (Selenium docs).
// invoices.mjs
import { chromium } from "playwright";
const url = "https://portal.example.com/invoices";
const login = process.env.LOGIN === "1";
const context = await chromium.launchPersistentContext("./portal-profile", { headless: !login });
const page = await context.newPage();
await page.goto(url);
if (login) {
await page.pause(); // log in by hand, then press Resume
await page.goto(url);
}
if (page.url().includes("/login")) throw new Error("session expired: run again with LOGIN=1");
const links = page.getByRole("link", { name: "Download PDF" });
await links.first().waitFor(); // fails the run if no invoice links show up
for (const link of await links.all()) {
const downloadPromise = page.waitForEvent("download"); // start waiting before the click
await link.click();
const download = await downloadPromise;
await download.saveAs(`invoices/${download.suggestedFilename()}`);
}
await context.close(); // downloads are deleted when the context closes, so save firstOption 3 in five steps: Browser Use Cloud
- Save the login in a profile. Create a profile under Dashboard > Profiles, then log in once in a browser that uses it; the login is saved to the profile when that browser stops (profiles). Keep the profile ID.
- Put the password and TOTP in 1Password. A dedicated vault, a service account with read access, connected under Settings > Secrets (1Password guide). The
one-time-passwordfield types the current code. Needed only when the portal logs you out. - Write the run and collect the files. List the browsers the run's session used (
agentSessionId), then each browser's downloads (downloads API):
# daily_invoices.py
import os, pathlib, time, requests
from browser_use_sdk.v4 import BrowserUse
API = "https://api.browser-use.com/api/v4"
KEY = {"X-Browser-Use-API-Key": os.environ["BROWSER_USE_API_KEY"]}
def op(alias, field): # one 1Password field, typed only on the portal's host
return {"alias": alias, "allowedDomains": ["portal.example.com"],
"source": {"type": "onepassword", "integrationId": os.environ["OP_INTEGRATION_ID"],
"vaultId": os.environ["OP_VAULT_ID"], "itemId": os.environ["OP_ITEM_ID"],
"fieldId": field}}
def get(path, **params):
r = requests.get(f"{API}{path}", headers=KEY, params=params, timeout=60)
r.raise_for_status()
return r.json()
def downloads(browser_id): # 50 files per page; follow the cursor
files, params = [], {"includeUrls": "true"}
while True:
page = get(f"/browsers/{browser_id}/downloads", **params)
files += page["files"]
if not page.get("hasMore"):
return files
params["cursor"] = page["nextCursor"]
with BrowserUse() as client:
run = client.runs.create(
"Open https://portal.example.com/invoices. If asked to log in, use portal_user, "
"portal_pass and portal_otp. Download every invoice dated yesterday as PDF "
"and report the invoice numbers.",
browser_settings={"profileId": os.environ["PORTAL_PROFILE_ID"]},
secret_bindings=[op("portal_user", "username"), op("portal_pass", "password"),
op("portal_otp", "one-time-password")],
)
done = client.runs.wait_for_completion(run.id)
if done.status.value != "completed":
raise SystemExit(f"run failed: {done.error or done.status.value}")
browsers = get("/browsers", agentSessionId=run.session_id)["items"]
files, last = [], None
for _ in range(18): # files can land after the run ends: wait until two polls agree (max 3 min)
files = [f for b in browsers for f in downloads(b["id"])]
if files and len(files) == last:
break
last = len(files)
time.sleep(10)
if not files or any(f["size"] == 0 for f in files): # drop if some days have no invoices
raise SystemExit(f"no invoice files; agent said: {done.result}")
out = pathlib.Path("invoices")
out.mkdir(exist_ok=True)
for f in files: # download links expire after 15 minutes
r = requests.get(f["url"], timeout=120)
r.raise_for_status()
(out / f["path"]).write_bytes(r.content)
print(done.result)- Copy the files to your storage. S3, SharePoint, Drive: add it after the loop, or as a workflow step. Keep the agent's reported invoice numbers next to the files so you can compare them.
- Schedule it. The public V4 API reference has no schedule endpoint, so the trigger is yours. On a server, a crontab line. On GitHub Actions:
# .github/workflows/invoices.yml
name: Pull invoices
on:
schedule:
- cron: "17 6 * * *" # 06:17 UTC daily
workflow_dispatch:
jobs:
invoices:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install browser-use-sdk requests
- run: python daily_invoices.py
env:
BROWSER_USE_API_KEY: ${{ secrets.BROWSER_USE_API_KEY }}
PORTAL_PROFILE_ID: ${{ secrets.PORTAL_PROFILE_ID }}
OP_INTEGRATION_ID: ${{ secrets.OP_INTEGRATION_ID }}
OP_VAULT_ID: ${{ secrets.OP_VAULT_ID }}
OP_ITEM_ID: ${{ secrets.OP_ITEM_ID }}GitHub runs schedules in UTC unless you set a timezone, can delay or drop them at busy times such as the top of the hour, and turns them off in public repos after 60 days without activity (GitHub docs).
When a run fails
- Session expired. The run lands on the login page. Playwright: log in again (
LOGIN=1); its docs say to delete stored state when it expires (docs). Browser Use: log in again in a browser that uses the profile (docs). - 2FA prompt. The run stops at a code screen. TOTP: a 1Password
one-time-passwordbinding types the code (docs). A push or SMS to someone's phone needs that person: hand them the live view (docs). - Layout change. A selector times out, or the agent reports it can't find the invoices. Playwright: fix the selector. Agent: read its report and check the files.
- Empty download. Zero files or a 0-byte file. Fail the run on it, as the scripts above do, and compare the file count with the invoice list.
- Nobody hears about it. Exit non-zero. GitHub notifies whoever last edited the cron line; add a Slack or email step for a team. A dropped schedule raises no error, so also alert when no run succeeded in 24 hours.
More
- How web agents log in, in depth: Web Agent Authentication
- 2FA methods the agent handles on its own: /login-automation
- Uploading invoices and filling portal forms: /form-filling

