Browser Proxy
Pay at a merchant's checkout from browser automation with a real card, while your automation never handles the card number.Browser automation can reach a merchant's payment form on its own, but paying means typing a card number into the page. If your automation types it, your automation, the servers it runs on, and everything they log are in PCI DSS scope.
Basis Theory gives you two ways to get the card into a browser-driven checkout using Reactors and the Proxy, with no model involved:
- Intercept. Your automation types a placeholder card, captures the checkout request the page sends, and forwards it to Basis Theory with token expressions in place of the card fields. Basis Theory sends the real request to the merchant. The real card never enters the browser.
- Inject. Your automation hands an open cloud browser session to a Reactor. The Reactor attaches to the session and enters the card into the payment form with fixed code, then submits it.
| Intercept | Inject | |
|---|---|---|
| Where the card goes | Basis Theory to the merchant's endpoint | Into the page in the cloud browser |
| Who can see the card | Basis Theory and the merchant | Basis Theory, the browser provider, the merchant page, and anyone who can connect to the session while it holds the card |
| Breaks when | The page encrypts card fields before sending them, the merchant collects cards in a payment provider's iframe, or the merchant blocks requests that do not come from the browser | The payment fields ignore programmatic input, the checkout spans several pages, or the fields are in a cross-origin iframe |
| You build | The browser automation and a matcher for each merchant's payment request | The browser automation, the merchant configuration (origin, checkout path, and field selectors), and the mandate your backend signs |
If you want a model to decide what to do in the browser, see Agents and Browsers, which gives an agent running in a Reactor the same guarded actions as inject.
Getting Started
To get started, you will need to create a Basis Theory Account and a TEST Tenant.
Intercept the Checkout Request
Your automation needs a Basis Theory key that can use the card's token in the Proxy. Create a Private Application with an access rule that grants token:use on only the container that holds the cards your automation pays with (card tokens are in /pci/high/ unless you choose another container), rather than an application with proxy:invoke, which can use any token in the tenant.
For each merchant, write a matcher: the URL and method of the payment request, and where each card field sits in its body. Inspect the request in your browser's developer tools while you pay with a test card. The example below handles a JSON request with a card object; for a form-encoded body, replace the values in request.postData() instead.
The script uses Browserbase and Playwright, and runs on your server, not in Basis Theory. Install @browserbasehq/sdk and playwright-core, then set BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, BT_PRIVATE_API_KEY, CARD_TOKEN_ID, CHECKOUT_URL (the merchant's checkout page), and PAYMENT_URL (the payment request's URL without its query string) in the environment.
The script fails closed. It aborts any request that carries the placeholder card but is not the matched payment request, forwards the matched request at most once, and throws unless exactly one payment request went through Basis Theory. If the merchant changes its payment endpoint, the checkout stops instead of sending the placeholder to the merchant.
import Browserbase from "@browserbasehq/sdk";
import { chromium } from "playwright-core";
const CARD_TOKEN_ID = process.env.CARD_TOKEN_ID;
// A placeholder that passes the page's validation. It must never reach the merchant.
const PLACEHOLDER = { number: "4111111111111111", expiry: "12/30", cvc: "123" };
// The merchant's payment request, and where each card field sits in its JSON body.
const matcher = {
url: process.env.PAYMENT_URL,
method: "POST",
replace: (body) => {
body.card.number = `{{ token: ${CARD_TOKEN_ID} | json: "$.data.number" }}`;
body.card.exp_month = `{{ token: ${CARD_TOKEN_ID} | json: "$.data" | card_exp: "MM" }}`;
body.card.exp_year = `{{ token: ${CARD_TOKEN_ID} | json: "$.data" | card_exp: "YY" }}`;
body.card.cvc = `{{ token: ${CARD_TOKEN_ID} | json: "$.data.cvc" }}`;
return body;
},
};
const browserbase = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });
const session = await browserbase.sessions.create({
projectId: process.env.BROWSERBASE_PROJECT_ID,
});
const browser = await chromium.connectOverCDP(session.connectUrl);
const page = browser.contexts()[0].pages()[0];
// Every request that carries the placeholder card must be the matched payment request,
// sent once. Anything else is aborted, so the placeholder never reaches the merchant.
let forwarded = 0;
await page.route("**/*", async (route) => {
const request = route.request();
if (!(request.postData() ?? "").includes(PLACEHOLDER.number)) return route.fallback();
const url = new URL(request.url());
const matches = request.method() === matcher.method && url.origin + url.pathname === matcher.url;
if (!matches || forwarded > 0) return route.abort("blockedbyclient");
forwarded += 1;
// Basis Theory replaces the expressions with the card data and calls the merchant.
const headers = await request.allHeaders();
delete headers["content-length"];
const response = await fetch("https://api.test.basistheory.com/proxy", {
method: request.method(),
headers: {
...headers,
"BT-API-KEY": process.env.BT_PRIVATE_API_KEY,
"BT-PROXY-URL": request.url(),
},
body: JSON.stringify(matcher.replace(request.postDataJSON())),
});
await route.fulfill({
status: response.status,
contentType: response.headers.get("content-type") ?? undefined,
body: Buffer.from(await response.arrayBuffer()),
});
});
// Your automation drives the checkout as usual, with the placeholder card.
await page.goto(process.env.CHECKOUT_URL);
await page.fill("#card-number", PLACEHOLDER.number);
await page.fill("#card-expiry", PLACEHOLDER.expiry);
await page.fill("#card-cvc", PLACEHOLDER.cvc);
await page.click("#place-order");
const result = await page
.locator("#result")
.filter({ hasText: /\S/ })
.textContent({ timeout: 15000 })
.catch(() => null);
await browser.close();
if (forwarded !== 1) throw new Error("The payment request was not forwarded through Basis Theory.");
console.log(result);
Order ord_1790883464951 confirmed. Card ending 4242 charged.
The confirmation shows the real card's last four digits: the merchant received the card from Basis Theory, not the placeholder the browser typed.
Before you rely on intercept for a merchant, check these limits:
- Client-side encryption and hosted fields. If the page encrypts card fields before sending them, or collects the card in a payment provider's iframe that tokenizes it, the captured request carries ciphertext or a provider token instead of card fields, and there is nothing to replace. Forwarding to a Reactor that reproduces the merchant's encryption can work, but it is specific to each merchant and breaks when they change their integration.
- Request origin. The merchant receives the payment request from Basis Theory's IP addresses, not from the browser. Merchants with bot defenses may reject it.
- Matchers drift. A matcher stops working when the merchant changes the payment endpoint or the shape of its body. The script then fails the checkout rather than paying, so monitor for those failures and update the matcher.
Inject the Card into a Browser Session
Injecting puts the card number into a cloud browser. The browser provider, its session recordings, screenshots, logs, network captures (HAR files), and live views, and anyone who can connect to the session while the card is in the page, can see it. Create sessions with recording and logging turned off, release them when the checkout finishes, and assess the provider as part of your PCI DSS scope before you use real cards.
What You Build
Browser automation. Your automation, which can be a script or an agent outside Basis Theory, drives the browser to the merchant's payment step and then stops interacting with the page until the Reactor returns.
A browser session the Reactor can attach to. Create Browserbase sessions with keepAlive: true, so the session survives when your automation disconnects, and with recordSession and logSession set to false. Browserbase only accepts these settings when the session is created.
Merchant configuration. For each merchant, the Reactor's configuration holds the checkout page's origin and path, the currency, and the selectors for the order total, the card fields, the submit button, and the element that shows the result. The Reactor fills only these fields, on only this page. The caller cannot supply or change them.
A signed mandate. When the customer approves a purchase, your backend records what they approved, signs it with a key shared with the Reactor, and passes it to the Reactor. The Reactor checks the signature, the expiration, and that the order total on the page does not exceed the approved amount before it enters the card. Whoever invokes the Reactor cannot raise the amount, change the merchant, or point it at another session.
Write the Reactor
const crypto = require("crypto");
module.exports = async function (event) {
const { req, configuration } = event;
// Your backend signs the mandate when the customer approves the purchase.
const mandate = verifyMandate(req.mandate, req.signature, configuration.MANDATE_SIGNING_KEY);
if (!mandate) return reply(403, { status: "rejected", reason: "invalid_mandate" });
// The checkout page and its fields come from configuration, never from the caller.
const merchant = JSON.parse(configuration.MERCHANTS)[mandate.merchant];
if (!merchant) return reply(400, { status: "rejected", reason: "unknown_merchant" });
const { default: Browserbase } = await import("@browserbasehq/sdk");
const { chromium } = await import("playwright-core");
const browserbase = new Browserbase({ apiKey: configuration.BROWSERBASE_API_KEY });
const session = await browserbase.sessions.retrieve(mandate.session_id);
const browser = await chromium.connectOverCDP(session.connectUrl);
try {
const page = browser.contexts()[0].pages().find((candidate) => {
const url = new URL(candidate.url());
return url.origin === merchant.origin && url.pathname === merchant.checkout_path;
});
if (!page) return reply(409, { status: "rejected", reason: "checkout_not_open" });
// Pin the checkout document and verify it before any card data is sent to the browser.
// If the page navigates after this point, calls through the handle fail instead of
// reaching the new document.
const checkout = await page.evaluateHandle(() => document);
if (!(await checkout.evaluate(isCheckout, merchant))) {
return reply(409, { status: "rejected", reason: "checkout_not_open" });
}
const card = req.card;
const filled = await checkout.evaluate(fillAndSubmit, {
merchant,
mandate,
fields: {
card_number: card.number,
expiry: `${String(card.expiration_month).padStart(2, "0")}/${String(card.expiration_year).slice(-2)}`,
security_code: card.cvc,
},
});
if (filled.status !== "submitted") return reply(409, filled);
// Wait for the merchant's response, then clear the card fields from the page.
const outcome = await page
.locator(merchant.selectors.result)
.filter({ hasText: /\S/ })
.textContent({ timeout: 15000 });
await checkout.evaluate(clearFields, merchant).catch(() => {});
return reply(200, { status: "submitted", total_minor_units: filled.total_minor_units, outcome });
} catch (error) {
// Fail closed. Do not retry automatically: the card may already have been submitted.
return reply(409, { status: "aborted", reason: "checkout_changed" });
} finally {
await browser.close();
}
};
function isCheckout(doc, merchant) {
return doc.location.origin === merchant.origin && doc.location.pathname === merchant.checkout_path;
}
// Runs inside the checkout document as one synchronous task, so the page cannot navigate
// between the checks and the fill.
function fillAndSubmit(doc, { merchant, mandate, fields }) {
if (doc.location.origin !== merchant.origin || doc.location.pathname !== merchant.checkout_path) {
return { status: "rejected", reason: "checkout_not_open" };
}
const totalText = doc.querySelector(merchant.selectors.total)?.textContent || "";
const match = /(\d+)\.(\d{2})/.exec(totalText.replace(/,/g, ""));
if (!match) return { status: "rejected", reason: "total_not_found" };
const total = Number(match[1]) * 100 + Number(match[2]);
if (merchant.currency !== mandate.currency || total > mandate.max_amount_minor_units) {
return { status: "rejected", reason: "mandate_mismatch", total_minor_units: total };
}
const inputs = Object.keys(fields).map((name) => doc.querySelector(merchant.selectors[name]));
const submit = doc.querySelector(merchant.selectors.submit);
if (inputs.some((input) => !input) || !submit) {
return { status: "rejected", reason: "field_not_found" };
}
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
Object.values(fields).forEach((value, index) => {
setValue.call(inputs[index], value);
inputs[index].dispatchEvent(new Event("input", { bubbles: true }));
inputs[index].dispatchEvent(new Event("change", { bubbles: true }));
});
submit.click();
return { status: "submitted", total_minor_units: total };
}
function clearFields(doc, merchant) {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
for (const name of ["card_number", "expiry", "security_code"]) {
const input = doc.querySelector(merchant.selectors[name]);
if (input) setValue.call(input, "");
}
}
function verifyMandate(mandate, signature, key) {
if (typeof mandate !== "string" || typeof signature !== "string") return null;
const expected = crypto.createHmac("sha256", key).update(mandate).digest();
const presented = Buffer.from(signature, "hex");
if (presented.length !== expected.length || !crypto.timingSafeEqual(presented, expected)) {
return null;
}
const parsed = JSON.parse(mandate);
if (!Number.isSafeInteger(parsed.max_amount_minor_units) || !(Date.parse(parsed.expires_at) > Date.now())) {
return null;
}
return parsed;
}
function reply(statusCode, body) {
return { res: { statusCode, body } };
}
{
"dependencies": {
"@browserbasehq/sdk": "2.21.0",
"playwright-core": "1.59.1"
}
}
Put the Browserbase API key, the mandate signing key, and the merchant configuration in a .env file. Generate the signing key with openssl rand -hex 32 and store the same value in your backend.
BROWSERBASE_API_KEY=<BROWSERBASE_API_KEY>
MANDATE_SIGNING_KEY=<MANDATE_SIGNING_KEY>
MERCHANTS='{"example-store":{"origin":"https://shop.example.com","checkout_path":"/checkout","currency":"USD","selectors":{"total":"#order-total","card_number":"#card-number","expiry":"#card-expiry","security_code":"#card-cvc","submit":"#place-order","result":"#result"}}}'
fillAndSubmit is how the Reactor keeps the card out of any other page. Before any card data is sent to the browser, the Reactor pins the checkout document with page.evaluateHandle and confirms its origin and path. It then runs fillAndSubmit through that handle, as one synchronous task inside the page: it checks the page again, parses the total in minor units, compares it with the mandate, fills the fields, and clicks submit. If the page navigates at any point after the document is pinned, the call fails instead of running in the new document, and the Reactor returns aborted without retrying. After the merchant responds, the Reactor clears the card fields.
The Reactor sets field values and dispatches input and change events, which works with standard inputs and common front-end frameworks. Payment fields that only accept real keyboard input, and fields inside a payment provider's cross-origin iframe, need a different approach.
Create the Reactor
Create the Reactor with the Basis Theory CLI and the Node.js runtime. The Reactor needs no Basis Theory permissions; press Enter when the CLI asks for them.
bt reactors create \
--name "Checkout Injector" \
--code ./reactor.js \
--package-json ./package.json \
--configuration ./.env \
--image node24 \
--timeout 30 \
--no-async \
--warm-concurrency 0 \
--resources standard
The Reactor pins playwright-core 1.59.1. Starting with 1.60.0, playwright-core loads restricted modules or reads restricted file paths when it is imported, so it fails in the sandbox.
Hand Off the Session
Your automation invokes the Reactor once it reaches the payment step. It needs a Private Application with reactor:invoke, or an access rule granting token:use on the card's container. The script below runs on your server; set BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, BT_PRIVATE_API_KEY, CARD_TOKEN_ID, CHECKOUT_URL, MANDATE_SIGNING_KEY, and REACTOR_ID in the environment.
import crypto from "node:crypto";
import Browserbase from "@browserbasehq/sdk";
import { chromium } from "playwright-core";
const browserbase = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });
// 1. Start a browser session that keeps running between connections, with recording and
// logging off so neither captures the card data the Reactor enters.
const session = await browserbase.sessions.create({
projectId: process.env.BROWSERBASE_PROJECT_ID,
keepAlive: true,
browserSettings: { recordSession: false, logSession: false },
});
// 2. Drive the browser to the merchant's payment step (your automation or agent does this).
const browser = await chromium.connectOverCDP(session.connectUrl);
const page = browser.contexts()[0].pages()[0];
await page.goto(process.env.CHECKOUT_URL);
await browser.close();
// 3. Sign the mandate the customer approved.
const mandate = JSON.stringify({
session_id: session.id,
merchant: "example-store",
max_amount_minor_units: 5000,
currency: "USD",
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
});
const signature = crypto
.createHmac("sha256", process.env.MANDATE_SIGNING_KEY)
.update(mandate)
.digest("hex");
// 4. Hand the session to the Reactor with a token expression for the card.
const response = await fetch(
`https://api.test.basistheory.com/reactors/${process.env.REACTOR_ID}/react`,
{
method: "POST",
headers: {
"BT-API-KEY": process.env.BT_PRIVATE_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
mandate,
signature,
card: `{{ token: ${process.env.CARD_TOKEN_ID} | json: "$.data" }}`,
}),
}
);
console.log(response.status, await response.json());
await browserbase.sessions.update(session.id, {
projectId: process.env.BROWSERBASE_PROJECT_ID,
status: "REQUEST_RELEASE",
});
200 {
status: 'submitted',
total_minor_units: 4200,
outcome: 'Order ord_1790879363309 confirmed. Card ending 4242 charged.'
}
When the total exceeds the mandate, the Reactor returns before it touches any field:
409 {
status: 'rejected',
reason: 'mandate_mismatch',
total_minor_units: 4200
}
The Reactor does not remember which mandates it has used. Record each handoff in your backend and do not retry one automatically after a timeout or an aborted result, because the order may already have been submitted. Check the merchant's confirmation first.
Timing
A synchronous Reactor invocation must finish within 30 seconds. In testing, a warm invocation of this Reactor took about 2 seconds, and the first invocation after provisioning, which loads the Browserbase SDK and Playwright, took about 13 seconds. See Choose an Agent Framework for ways to reduce cold starts.