Download invoices from a portal behind a login with Axiom Chrome API
The Chrome API is a cloud browser you drive over Chrome DevTools Protocol with Playwright or Puppeteer, so you get full control from your own code, including file downloads. This guide signs in to a portal, finds every invoice link, and saves each PDF to disk. Because the download reuses the signed-in session, it works even when the files sit behind a login.
Before you begin
- Node.js 18 or later.
- Playwright installed:
npm install playwright. - An axiom.ai API key from the Code Dashboard, set as the
AXIOM_API_KEYenvironment variable. - Your portal credentials in environment variables, for example
PORTAL_USERandPORTAL_PASS. - The CSS selectors for the login fields and the invoice download links.
Connect to the cloud browser
Connect over CDP to a fresh cloud browser. There's no Chromium to install locally.
import { chromium } from 'playwright';
const browser = await chromium.connectOverCDP(
`wss://cdp-lb.axiom.ai/?token=${process.env.AXIOM_API_KEY}`
);
const context = browser.contexts()[0];
const page = context.pages()[0] ?? await context.newPage();
Note: Puppeteer works too:
puppeteer.connect({ browserWSEndpoint: "wss://cdp-lb.axiom.ai/?token=..." }). This guide uses Playwright.
Sign in
Fill the login form and submit. Keep credentials in environment variables, never in the script.
await page.goto('https://portal.example.com/login');
await page.fill("input[name='email']", process.env.PORTAL_USER);
await page.fill("input[name='password']", process.env.PORTAL_PASS);
await page.click("button[type='submit']");
await page.waitForLoadState('networkidle');
Collect the invoice links
Open the invoices page and read every download link's URL. Adjust the selector to match the portal.
await page.goto('https://portal.example.com/invoices');
const links = await page.$$eval(
"a[href$='.pdf']", // match the portal's invoice download links
(anchors) => anchors.map((a) => a.href)
);
console.log(`Found ${links.length} invoices`);
Download each PDF
Fetch each file with the browser context's request client. It shares the signed-in session's cookies, so authenticated downloads work without handling any download dialog. Save the bytes to disk.
import { writeFileSync, mkdirSync } from 'node:fs';
import { basename } from 'node:path';
mkdirSync('./invoices', { recursive: true });
for (const url of links) {
const response = await context.request.get(url);
const file = `./invoices/${basename(new URL(url).pathname)}`;
writeFileSync(file, await response.body());
console.log(`Saved ${file}`);
}
Note:
context.requestreuses the browser context's cookies, so the request is authenticated exactly like the browser. This is more reliable than intercepting browser download events over a remote connection.
Full script
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync } from 'node:fs';
import { basename } from 'node:path';
async function run() {
const browser = await chromium.connectOverCDP(
`wss://cdp-lb.axiom.ai/?token=${process.env.AXIOM_API_KEY}`
);
try {
const context = browser.contexts()[0];
const page = context.pages()[0] ?? await context.newPage();
// Sign in
await page.goto('https://portal.example.com/login');
await page.fill("input[name='email']", process.env.PORTAL_USER);
await page.fill("input[name='password']", process.env.PORTAL_PASS);
await page.click("button[type='submit']");
await page.waitForLoadState('networkidle');
// Collect invoice links
await page.goto('https://portal.example.com/invoices');
const links = await page.$$eval("a[href$='.pdf']", (anchors) =>
anchors.map((a) => a.href)
);
console.log(`Found ${links.length} invoices`);
// Download each PDF using the signed-in session
mkdirSync('./invoices', { recursive: true });
for (const url of links) {
const response = await context.request.get(url);
const file = `./invoices/${basename(new URL(url).pathname)}`;
writeFileSync(file, await response.body());
console.log(`Saved ${file}`);
}
} finally {
await browser.close();
}
}
run();
Run it with node download-invoices.js.
Next steps
- Handle pagination: if invoices span several pages, wrap the collect step in a loop that clicks Next until it disappears, gathering links as you go.
- Speed up navigation by blocking heavy resources:
await page.route("**/*", (route) => ["image", "media", "font"].includes(route.request().resourceType()) ? route.abort() : route.continue());. - Read the Chrome API guide for other connection options.