Skip to main content

Own the Browser Verification Flow

Drive every Agentic Payments verification action, provider browser surface, message listener, retry, and cleanup path yourself.

The Web Agentic SDK is the default integration path. Use this page only when your application needs to own the verification API state machine and the providers' browser protocols, not just the visual design of the prompts.

If you only need a custom interface, keep the SDK and use its appearance, strings, mixed handlers, or headless mode. Those options preserve the SDK's origin checks, synchronous popup handling, retries, polling, and teardown.

A direct implementation becomes security-sensitive browser infrastructure. Pin both message origin and source, open ceremony windows synchronously from the customer's click, treat every API status and error type as an open set, and remove every iframe, popup, listener, interval, and timeout on all settle paths.

This page is a protocol blueprint rather than a drop-in library. Functions named visaAuthFromEmbed, promptMethodSelection, promptOtpEntry, and getOrCreatePersistentDeviceId are integration points for your Visa adapter, UI, and device storage. The examples show the central API loop, browser-window ordering, and origin checks.

The flow you are taking ownership of is:

Complete Agentic Payments Setup first. This page assumes a public application with agentic:allowance:verify (created in the implementation guide), that your top-level page uses HTTPS, and that your Content Security Policy allows the Agentic API in connect-src. Each provider section adds its own browser requirements.

The examples use the production API origin. For a test tenant, set the API origin to https://api.test.basistheory.com.

The page is organized the way the API is: a rail-agnostic contract, then one self-contained section per rail and provider pair with a ceremony, then the single loop that drives them all.

Verify the Customer

A provider that requires verification will not release an agentic credential for a mandate the customer has not approved. Verification is how that approval happens, and it is scoped to one rail on one allowance: approving this allowance does not grant open-ended agentic access to the source.

Skip this flow entirely when every rail you intend to mint from is already active. Only a rail in pending_verification needs a ceremony.

Verification is not optional on a rail that requires it. An agentic-token rail is pending_verification from the moment the allowance is created; no provider activates it there, and only a cardholder ceremony, confirmed server to server with the network, moves it to active. Verify is safe to repeat, though: once a rail is active, calling verify again returns the active envelope without contacting the provider.

One Endpoint, One Loop

Every step of every ceremony, whichever provider owns it, goes through the Verify Allowance API. You send an action, you get back a state, and you repeat until the state is active.

Call this endpoint directly from the customer's browser with a public application scoped to agentic:allowance:verify. A private application with the same permission also works for server-side tests, but its key must never be exposed to the browser.

Start Verification
curl 'https://api.basistheory.com/agentic/allowances/alw_Xw92mKvB3dQz/verify' \
-X 'POST' \
-H 'BT-API-KEY: <PUBLIC_API_KEY>' \
-H 'Content-Type: application/json' \
--data '{
"rail": "agentic-token",
"provider": "vic",
"action": "start",
"display_name": "Example Agent",
"device_context": {
"language_code": "en-US",
"time_zone": "America/New_York",
"platform_type": "WEB"
}
}'
Response
{
"status": "verification_required",
"rail": "agentic-token",
"provider": "vic",
"next_action": { "type": "passkey_session", "embed": { "...": "..." } }
}

status is either verification_required or active. A verification_required response normally includes next_action, which tells your frontend what to do and which action to send back. A response without next_action means the provider is still processing; wait briefly and repeat the action — only Mastercard's complete responds this way.

next_action.typeWhat the customer doesYou send back
passkey_sessionNothing visible. Your page initializes Visa's hidden session iframesubmit_session with the secure token the iframe returns
select_otp_methodChooses where to receive a one-time codeselect_otp_method with the chosen method_id
otpEnters the code they receivedsubmit_otp with the otp_code
passkeyCompletes Visa's visible passkey ceremony in a popupsubmit_passkey with the resulting assurance_data
redirectCompletes Mastercard's hosted ceremony in a popup or new tabcomplete, with no additional fields

Which actions are valid depends on the provider. Visa accepts start, submit_session, select_otp_method, submit_otp, and submit_passkey. Mastercard accepts start and complete. Sending an action the provider does not support, or sending one out of order, returns 400.

Nothing your client sends can activate a rail on its own. Basis Theory confirms every transition with the provider server to server, and a browser event saying a ceremony finished is a cue to make the next API call rather than a result. Do not treat a popup closing, or a postMessage arriving, as verification succeeding.

display_name is your application or agent name as the customer sees it on the provider's own screens, in text such as "Add Allowance to Example Agent" and "Returning to Example Agent". It is 1 to 60 characters, defaults to Agent, and is sent once on start. Basis Theory retains it for the rest of the ceremony.

device_context is browser and device data the provider uses for risk assessment. Basis Theory validates language_code as a BCP 47 tag and time_zone as an IANA identifier, and forwards everything else to the provider unchanged. Send it once on start; Basis Theory retains it and reuses it on submit_session, so you never rebuild it just to advance the state machine. The Visa section below shows the full set of fields Visa expects and how to collect them.

Call the API from the Browser

Create a public application scoped to agentic:allowance:verify. Its key is publishable; never substitute a private application key here. Bind the required allowance, rail, and provider once, then use the returned verify function for every ceremony action:

agenticVerification.js
const agenticApiOrigin = "https://api.basistheory.com";

function createAllowanceVerifier({ allowanceId, provider, publicApiKey }) {
return async function verify(action, { signal } = {}) {
const response = await fetch(
`${agenticApiOrigin}/agentic/allowances/${encodeURIComponent(allowanceId)}/verify`,
{
method: "POST",
headers: {
"BT-API-KEY": publicApiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
rail: "agentic-token",
provider,
...action,
}),
signal,
},
);

const payload = await response.json();
if (!response.ok) {
const error = new Error(
payload.detail ?? payload.title ?? "Allowance verification failed",
);
error.status = response.status;
error.type = payload.type;
error.problem = payload;
throw error;
}
return payload;
};
}

const verify = createAllowanceVerifier({
allowanceId: "alw_Xw92mKvB3dQz",
provider: "vic", // use "agentpay" for a Mastercard allowance rail
publicApiKey: "<PUBLIC_API_KEY>",
});

Errors keep their Problem Details type on error.type, so your browser logic can branch without parsing messages. Treat it as an open set. verify also accepts an optional AbortSignal and forwards it to fetch, so cancelling the surrounding UI cancels an in-flight request too.

Open Ceremonies from a User Gesture

Every ceremony requires the customer-facing window to be opened directly from a user gesture. Call window.open synchronously inside the click handler. A popup opened after an await has lost browser user activation and will be blocked, and the WebAuthn ceremony inside it will not run.

The state machine reaches a visible ceremony only after one or more API calls, so render a new button and invoke the ceremony from that button's click. This minimal helper demonstrates the required ordering; replace its placement and copy with your own UI. It accepts the loop's AbortSignal and keeps observing it until the ceremony promise settles, so dismissing the UI rejects whether the button was never clicked or the ceremony is still pending:

Continue from a User Gesture
function continueFromUserClick(label, openCeremony, { signal } = {}) {
return new Promise((resolve, reject) => {
// Abort events are not replayed: reject an already-aborted signal up
// front, before the button exists or the abort listener registers below.
if (signal?.aborted) {
reject(signal.reason ?? new DOMException("Cancelled", "AbortError"));
return;
}

const button = document.createElement("button");
button.type = "button";
button.textContent = label;

// One settle path, run exactly once. The abort listener stays registered
// until the ceremony promise itself settles, not just until the click.
let settled = false;
const settle = (complete, value) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", onAbort);
button.remove();
complete(value);
};

const onAbort = () =>
settle(reject, signal.reason ?? new DOMException("Cancelled", "AbortError"));
signal?.addEventListener("abort", onAbort, { once: true });

button.addEventListener(
"click",
() => {
button.remove();
try {
// Keep this call synchronous in the click listener.
Promise.resolve(openCeremony()).then(
(value) => settle(resolve, value),
(error) => settle(reject, error),
);
} catch (error) {
settle(reject, error);
}
},
{ once: true },
);

document.body.append(button);
});
}

That is the whole rail-agnostic contract. Each section that follows is one complete recipe for a rail and provider pair with a ceremony: its API branches, its browser surfaces, and its recovery paths. Implement the ones your rails use, then close with the single loop that drives them all.

Visa Intelligent Commerce

Rail agentic-token, provider vic.

Visa's ceremony has two branches, and which one you get depends on whether this cardholder's device already holds a Visa Payment Passkey. Your code does not choose: Visa decides, and tells you through next_action.

Two details in that diagram are easy to miss.

passkey_session is not the cardholder-facing step. Visa uses two browser surfaces. A hidden iframe exists only to establish the session and hand back a secure token, and the visible REGISTER or AUTHENTICATE ceremony runs in a popup the cardholder opened. submit_session carries only session_context.secure_token; the device context you sent on start is already retained server-side.

After a REGISTER ceremony, start over. Registration binds the passkey to the device but does not authenticate the mandate. Send start again, which opens a fresh session and lands you in the AUTHENTICATE branch, and submit that ceremony's assurance data.

Visa has no polling action. Each state must be advanced by performing the action it asked for.

The rest of this section is the browser code those branches need: the hidden session iframe, the visible passkey popup, and the one-time-code forms.

Before You Write Visa Code

These come from Setup, and skipping them produces failures that look like bugs in your own integration:

  • Ad/content blockers, privacy tools, browser security settings, and network filters must not block Visa's hosted iframe. If it never initializes, inspect those controls along with your CSP and network requests.
  • Your page must be served over HTTPS. Passkeys require a secure top-level origin, so use a tunnel for local development.
  • Your Content Security Policy must allow Visa's iframe origin in frame-src, and the iframe element needs allow="publickey-credentials-get; publickey-credentials-create" to run WebAuthn.

Collect Device Context

Collect this once and send it with start. Basis Theory validates language_code and time_zone and forwards the rest to Visa unchanged, so Visa is the authority on the other fields.

collectDeviceContext.js
function collectDeviceContext() {
return {
screen_height: window.screen.height,
screen_width: window.screen.width,
user_agent_string: navigator.userAgent,
language_code: navigator.language, // BCP 47, e.g. "en-US"
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone, // IANA
java_script_enabled: true,
client_device_id: getOrCreatePersistentDeviceId(), // stable per device
client_reference_id: crypto.randomUUID(), // new per verification session
platform_type: "WEB", // common values: WEB, MOBILE, NATIVE
};
}

Keep client_device_id stable for a device, for example a random value persisted in localStorage, and generate a fresh client_reference_id for each verification session.

Visa also accepts color_depth, accept_header, and ip_address. If you send ip_address, inject the client IP from your own backend and send IPv4 only. Visa rejects IPv6, and Basis Theory forwards whatever you send without rewriting it.

Initialize Visa's Hidden Session iframe

Everything needed to mount it arrives in the passkey_session action's embed block as iframe_url, api_key, and client_app_id. These are publishable-class identifiers served by the API rather than values you hard-code, so they can rotate without a release on your side.

Mount the iframe, run Visa's message protocol (AUTH_READY, then CREATE_AUTH_SESSION, then AUTH_SESSION_CREATED), and send back only the secure token.

Initialize the session
const manager = await visaAuthFromEmbed(nextAction.embed);
const visaSession = await manager.createSession();

const state = await verify({
action: "submit_session",
session_context: { secure_token: visaSession.sessionContext.secureToken },
});

Create a session iframe for each passkey_session response and keep that session alive until the next start or until verification settles. A REGISTER result restarts from start, which returns a fresh embed action; dispose the previous iframe before mounting the next one.

Run Visa's Visible Passkey Ceremony

The passkey action runs in a popup opened from the cardholder's click. The same hosted URL from embed backs both surfaces; what differs is that this one is visible and user-initiated. Map passkey_context onto Visa's authentication context and forward the values as given.

Run the ceremony
async function runVisaPasskeyCeremony(nextAction, verify, manager, { signal } = {}) {
const ctx = nextAction.passkey_context;

const authResult = await manager.authenticate({
endpoint: ctx.endpoint,
identifier: ctx.identifier,
payload: ctx.payload,
action: ctx.action, // 'REGISTER' or 'AUTHENTICATE'. Visa decides, not you
platformType: ctx.platform_type,
authenticationPreferencesEnabled: {
responseMode: ctx.auth_preferences?.response_mode,
responseType: ctx.auth_preferences?.response_type,
},
});

if (ctx.action === "AUTHENTICATE") {
// The API expects exactly this snake_case shape.
return verify(
{
action: "submit_passkey",
assurance_data: {
identifier: authResult.assuranceData?.identifier,
dfp_session_id: authResult.assuranceData?.rpID,
fido_assertion_data: {
code: authResult.assuranceData?.fidoBlob,
},
},
},
{ signal },
);
}

// REGISTER only bound the passkey to this device. Start a fresh session,
// which lands in the AUTHENTICATE branch.
return verify(
{ action: "start", device_context: collectDeviceContext() },
{ signal },
);
}

One thing here catches people out beyond the REGISTER branch: forward response_mode and response_type exactly as returned. Visa picks the response mode for each specific ceremony, and Basis Theory explicitly requests Visa's popup preference for both REGISTER and AUTHENTICATE. Overriding it with a mode incompatible with a popup causes Visa to reject the ceremony.

Open a fresh popup from each user click and close it on every terminal Visa event.

Render the One-Time-Code Steps

select_otp_method and otp involve no SDK. They are plain forms in your own UI.

For select_otp_method, render next_action.methods as choices and post back the chosen method_id. For otp, render a code input using next_action.method, code_expiration_minutes, and max_attempts, then post back the otp_code.

Treat the expiry and attempt limit as display hints. The provider enforces both when it validates the code.

Visa also offers step-up methods that are not one-time codes, such as app-to-app and customer-service flows. Those are not supported, and they are filtered out of the choices presented to the cardholder. If Visa offers no supported one-time-code method for a card, verification returns 422 VERIFICATION_NOT_SUPPORTED. That card can still use the spt rail.

Ceremony-step failures are ordinary and recoverable. 400 INVALID_OTP means the code was wrong or expired; let the cardholder retry the step, since Visa enforces the attempt limit on its side. Once Visa refuses the session, or rejects the passkey assurance data, the request returns 422 PROVIDER_VERIFICATION_FAILED, and the recovery is to restart from start.

Mastercard Agent Pay

Rail agentic-token, provider agentpay.

Mastercard runs one hosted ceremony that handles everything, including creating a passkey when the cardholder does not have one. There is no separate registration step and no OTP branch to handle.

Open the uri from next_action top-level, in a popup or a new tab. Mastercard's page sends X-Frame-Options: DENY and cannot be embedded in an iframe at all.

There is only one way to finish this ceremony, and it is complete. The callback bridge and the mastercard_verification_complete message are a browser signal that the hosted ceremony returned, nothing more. complete is where Basis Theory retrieves the authentication result from Mastercard server to server, and only a Mastercard-confirmed result activates the rail.

That separation is what makes the flow robust. If the callback is lost, the popup is closed manually, or the message never arrives, you can still call complete and get an authoritative answer. Three outcomes are possible:

  • {"status": "active"}. The rail is verified and you can mint.
  • {"status": "verification_required"}. Mastercard has not finished. Retry complete after a short delay.
  • 422 PROVIDER_VERIFICATION_FAILED. The session failed or expired. Restart from start.

If the card does not support Mastercard's managed authentication, start fails immediately with 422 VERIFICATION_NOT_SUPPORTED rather than sending the cardholder into a ceremony that cannot complete.

Open the Hosted Redirect

When the cardholder finishes, Mastercard returns to a Basis Theory callback page that posts a message to your opener and closes itself.

Run the redirect
const abortableDelay = (ms, signal) =>
new Promise((resolve, reject) => {
// Abort events are not replayed: a signal that aborted before the
// listener below registers would otherwise wait out the full delay.
if (signal?.aborted) {
reject(signal.reason ?? new DOMException("Cancelled", "AbortError"));
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException("Cancelled", "AbortError"));
};
signal?.addEventListener("abort", onAbort, { once: true });
});

async function runMastercardRedirect(
nextAction,
verify,
agenticApiOrigin,
{ signal, timeoutMs = 5 * 60 * 1000 } = {},
) {
// Opened synchronously from the cardholder's click.
const popup = window.open(nextAction.uri, "mc-auth", "width=480,height=720");
if (!popup) throw new Error("The browser blocked the verification popup");

await new Promise((resolve, reject) => {
let settled = false;
let poll;
let timeout;

const settle = (error) => {
if (settled) return;
settled = true;
clearInterval(poll);
clearTimeout(timeout);
window.removeEventListener("message", onMessage);
signal?.removeEventListener("abort", onAbort);
popup.close();
if (error === undefined) resolve();
else reject(error);
};

const onMessage = (event) => {
if (event.origin !== agenticApiOrigin) return;
if (event.source !== popup) return;
if (event.data?.type === "mastercard_verification_complete") {
settle();
}
};

const onAbort = () => {
settle(
signal.reason ??
new DOMException("Mastercard verification was cancelled", "AbortError"),
);
};
window.addEventListener("message", onMessage);

signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) {
onAbort();
return;
}

// The cardholder may also close the popup themselves.
poll = setInterval(() => {
if (popup?.closed) {
settle();
}
}, 500);

// Never wait forever. The bridge message can be lost while the popup
// stays open, so stop waiting and let `complete` decide. It is the
// authoritative check either way, and calling it early is safe.
timeout = setTimeout(settle, timeoutMs);
});

// The message is only a cue. `complete` is the authoritative transition:
// Basis Theory retrieves the result from Mastercard server to server.
// The popup-wait listeners are gone at this point, so keep observing the
// signal here: before each request, inside fetch, and between attempts.
for (let attempt = 0; attempt < 10; attempt += 1) {
signal?.throwIfAborted();
const state = await verify({ action: "complete" }, { signal });
if (state.status === "active" || state.next_action) return state;
await abortableDelay(2000, signal);
}
throw new Error("Verification still pending; ask the cardholder to retry");
}

Always compare event.origin against the Agentic Payments API origin and event.source against the popup before trusting a message. Any page can post to your window. Pass an AbortSignal when the surrounding UI can be dismissed. The shared settle path removes the message and abort listeners, clears the close-poll and timeout, and closes the popup whether the ceremony completes, times out, or is cancelled. The authoritative loop then keeps observing the signal on its own: it checks before each complete, forwards the signal into fetch, and waits between attempts with an abortable delay, so dismissing the UI stops requests and timers immediately.

Drive Every Ceremony from One Loop

Every supported ceremony, including each branch of Visa's, reduces to one loop over next_action.type.

verifyUntilActive.js
async function verifyUntilActive(verify, { signal } = {}) {
let state = await verify(
{
action: "start",
display_name: "Example Agent",
device_context: collectDeviceContext(),
},
{ signal },
);
let visaManager;

try {
while (state.status !== "active") {
switch (state.next_action?.type) {
case "passkey_session": {
visaManager?.dispose();
visaManager = await visaAuthFromEmbed(state.next_action.embed, { signal });
const session = await visaManager.createSession();
state = await verify(
{
action: "submit_session",
session_context: { secure_token: session.sessionContext.secureToken },
},
{ signal },
);
break;
}
case "passkey":
state = await continueFromUserClick(
"Continue with Visa",
() =>
runVisaPasskeyCeremony(
state.next_action,
verify,
visaManager,
{ signal },
),
{ signal },
);
break;
case "select_otp_method":
state = await promptMethodSelection(state.next_action.methods, verify, { signal });
break;
case "otp":
state = await promptOtpEntry(state.next_action, verify, { signal });
break;
case "redirect":
state = await continueFromUserClick(
"Continue with Mastercard",
() =>
runMastercardRedirect(
state.next_action,
verify,
agenticApiOrigin,
{ signal },
),
{ signal },
);
break;
default:
throw new Error(`Unhandled verification state: ${state.status}`);
}
}
return state;
} finally {
visaManager?.dispose();
}
}

promptMethodSelection and promptOtpEntry are your own UI. visaAuthFromEmbed is your adapter for Visa's published iframe protocol. Each helper receives the ceremony's shared signal and must reject promptly when it aborts — including a signal that is already aborted before a listener is registered — and clean up its own listeners and embedded UI. The loop deliberately renders a fresh confirmation control for each visible ceremony so no window.open occurs after an API await. Passing the same signal into every stage means dismissing the surrounding UI settles whichever stage is active, including a confirmation button that was never clicked, and the finally block still disposes the Visa manager.

Handle the default case as a real error rather than silently exiting the loop. It means the API returned a state your version does not know about, and treating that as success would be the one mistake this design exists to prevent.

In a test tenant this same code runs unchanged against Basis Theory-hosted mocks of both ceremonies, because only the URLs inside the API responses differ. The testing reference describes what each mock screen shows.

The Web Agentic SDK implements these responsibilities for Agentic Payments. Keep this direct implementation only when owning the protocol itself is a requirement.