Skip to content

Guides

Embed the Connect Widget

Run the OAuth flow in a popup, inline inside the application layout, or a redirect, from a frontend, with one line of JavaScript.

By the end of this guide, a frontend launches an OAuth flow from a button click. On desktop, the user authorizes, the popup closes, and a callback receives the retained grants plus completion details. The launcher works with React, Vue, Angular, and plain HTML; it uses a popup on desktop and full-page navigation on mobile-classified devices, or renders inline inside an element the page provides.

The Connect widget is @alter-ai/connect, a framework-agnostic browser package that opens the Alter-hosted Connect UI in a popup, inline in a container, or via full-page navigation. It uses Zod to validate completion messages before application callbacks consume them.

  • An app with at least one provider configured.
  • A backend endpoint that mints Connect session tokens (the app key must stay on the backend — see Call APIs on behalf of users for the backend half).
  • A frontend with a <button> that triggers the connection.

When a session omits allowed_origin, Alter derives the browser origin from the app’s Website URL. That field is a full public URL up to 2,048 characters; embedded credentials are rejected. Paths, query strings, and fragments are allowed, while Connect uses only its scheme, host, and port for postMessage.

Terminal window
npm install @alter-ai/connect

Or via CDN:

<script src="https://cdn.jsdelivr.net/npm/@alter-ai/connect@latest/dist/alter-connect.umd.js"></script>
import AlterConnect from "@alter-ai/connect";
const alterConnect = AlterConnect.create();
// Fetch before enabling the button. open() must run directly in the click
// handler so the popup remains attached to the user gesture.
const { sessionToken } = await fetch("/api/connect-session", { method: "POST" }).then(r => r.json());
button.disabled = false;
button.addEventListener("click", () => {
void alterConnect.open({
token: sessionToken,
onSuccess: (grants, completion) => {
for (const grant of grants) {
console.log("Connected", grant.provider, grant.account_identifier);
}
if (completion.failedGrants.length > 0) {
console.warn("Some providers were not retained", completion.failedGrants);
}
},
onError: (error) => {
console.error("Connect failed", error.code, error.message);
},
onExit: () => {
console.log("User closed the popup");
},
});
});

open() resolves after the launch attempt, not after authorization completes. On desktop, completion arrives through onSuccess(grants, completion). grants contains every retained grant; completion.failedGrants reports providers whose grants were not retained after a partial success: reason: "not_selected" for a provider the user connected but did not select on the Confirm screen, reason: "activation_failed" for one that could not be completed when the user confirmed it, any other reason for usage limits that could not be applied.

Confirm needs at least one selected connection. When the Alter deployment holds new connections until Confirm, a new connection becomes usable only when the user confirms it: if the user closes Connect after signing in to the provider but before confirming, nothing is granted, the connection never becomes usable, and a backend poll of the session reports it expired. On a deployment that does not, the connection is usable as soon as the provider sign-in completes, without the usage limits or expiry the user had not yet confirmed.

A supplied container decides the flow on its own; without one, the widget picks between a popup and a full-page redirect by device:

ConditionFlow
container supplied, on any deviceInline frame inside an element the page supplies; provider sign-in opens in a new window from inside the widget (see Inline flow)
No container, desktopCentered popup, 500×700px, posts result via postMessage
No container, tablet landscapePopup
No container, phone (≤480px) or tablet portraitFull-page navigation; returns to the session’s return URL with the outcome

Full-page navigation destroys the JavaScript callbacks passed to that open() call. Set a return URL when creating the session and the flow navigates back to it with the outcome, which the AlterConnect instance constructed on that page load reads and delivers to the onSuccess or onError registered there. A session that authorized several providers returns with the first grant only.

Poll from the application backend when completion must survive navigation regardless — sessions created without a return URL, and returns that arrive more than five minutes after the flow started. Keep the session token on the backend, poll it with app.poll_connect_session() (Python) or app.pollConnectSession() (TypeScript), then expose the resulting status through the application’s own route.

To render Connect inside the application’s own layout instead of a popup window, the way Plaid’s embedded Link does, pass a container element. The widget fills the element, so give it a size (350×300 px or larger is recommended):

<div id="alter-connect" style="width: 420px; height: 560px;"></div>
const alterConnect = AlterConnect.create();
const { sessionToken } = await fetch("/api/connect-session", { method: "POST" }).then(r => r.json());
void alterConnect.open({
token: sessionToken,
container: document.getElementById("alter-connect")!,
onSuccess: (grants, completion) => { /* same as the popup flow */ },
onError: (error) => console.error("Connect failed", error.code, error.message),
onExit: () => console.log("User pressed Cancel in the widget"),
});

What to know:

  • Origin binding. The page embedding the widget must have the same origin as the session’s allowed_origin (or the app’s Website URL origin when allowed_origin is omitted). Any other page gets an error rendered inside the widget and onError fires with code: "embed_origin_mismatch". The page must also be a top-level document: a host page that is itself inside an iframe can never be the widget’s single allowed parent, and open() rejects with embed_unsupported_origin.
  • Provider sign-in opens in a new window. Provider login pages cannot be shown inside a frame, so the widget opens them in a window from the user’s click. Because that click happens inside the widget, open() does not need to run inside a click handler in this mode.
  • Where the user confirms. When the Alter deployment holds new connections until Confirm, the user confirms in that window: after the provider sign-in, the window shows the Confirm screen (the selection, any expiry and usage limits, and Confirm) while the widget shows that the flow continues there. Confirming delivers the outcome to the widget, which calls onSuccess or onError as usual. Confirm can then only be completed in the browser that completed the provider sign-in, and a connection the user leaves without confirming never becomes usable. On a deployment that does not, the Confirm screen shows in the widget after the provider sign-in.
  • Cancel is part of the widget and fires onExit. Like closing the popup, Cancel is a local transition: it does not mark the backend session denied, so a server-side poll stays pending until its timeout or the session expires. close() or destroy() removes the widget from the page.
  • Mobile devices use the inline widget too; the full-page redirect and return_url are never used when container is supplied.
  • Browser support. Current Chrome, Edge, Firefox, and Safari render the widget inline. A browser that refuses the widget’s storage inside a frame (older Safari, or third-party cookies blocked) is handled automatically: the widget offers “Continue in a new window” and completes the flow there through the same callbacks, reporting embed_unsupported through onEvent. The widget also refuses consent while the frame is scrolled out of view or the document is hidden; detecting an opaque overlay drawn over the frame by the host page is available on Chromium browsers only.
  • Load failures are reported. A frame that delivers no message within 15 seconds (refused by the browser, failed to load, or unreachable) fires onError with code: "embed_load_timeout". A session the hosted page cannot bind inside the frame renders the error in the widget and posts it to onError with the server’s code (embed_origin_mismatch, embed_not_allowed, session_expired, embed_verify_failed, or cookies_required). A 409 embed_mode_conflict means the session already continued in a new window; the widget offers that window again rather than failing.

React:

import { useEffect, useRef, useState } from "react";
import AlterConnect from "@alter-ai/connect";
function ConnectPanel({ sessionToken }) {
const containerRef = useRef(null);
const [alterConnect] = useState(() => AlterConnect.create());
useEffect(() => {
if (!sessionToken || !containerRef.current) return;
void alterConnect.open({
token: sessionToken,
container: containerRef.current,
onSuccess: (grants) => { /* … */ },
onExit: () => { /* … */ },
});
return () => alterConnect.destroy();
}, [alterConnect, sessionToken]);
return <div ref={containerRef} style={{ width: 420, height: 560 }} />;
}
import { useEffect, useState } from "react";
import AlterConnect from "@alter-ai/connect";
function ConnectButton() {
const [alterConnect] = useState(() => AlterConnect.create());
const [sessionToken, setSessionToken] = useState(null);
useEffect(() => {
let isMounted = true;
fetch("/api/connect-session", { method: "POST" })
.then(r => r.json())
.then(({ sessionToken }) => {
if (isMounted) setSessionToken(sessionToken);
});
return () => {
isMounted = false;
alterConnect.destroy();
};
}, [alterConnect]);
const handleConnect = () => {
if (!sessionToken) return;
void alterConnect.open({
token: sessionToken,
onSuccess: (grants, completion) => { /* … */ },
});
};
return (
<button disabled={!sessionToken} onClick={handleConnect}>
Connect Google
</button>
);
}

The session is ready before the button is enabled, so open() runs directly in the click handler. Fetching the session inside that handler — with either await or a .then() callback — can detach window.open() from the user gesture and trigger a popup blocker.

<script setup>
import { onMounted, ref } from "vue";
import AlterConnect from "@alter-ai/connect";
const alterConnect = AlterConnect.create();
const sessionToken = ref(null);
onMounted(async () => {
const response = await fetch("/api/connect-session", { method: "POST" });
const session = await response.json();
sessionToken.value = session.sessionToken;
});
function handleConnect() {
if (!sessionToken.value) return;
void alterConnect.open({
token: sessionToken.value,
onSuccess: (grants, completion) => { /* … */ },
});
}
</script>
<template>
<button :disabled="!sessionToken" @click="handleConnect">Connect Google</button>
</template>

When a stored grant’s connection breaks (refresh token revoked, user changed password at the provider), the SDK raises CredentialRevokedError on the next API call. Trigger the widget with a fresh session from the same session-creation flow; the resulting OAuth completes as a re-auth and the grant_id stays the same. Each reauthenticated item in the grants array has operation: "reauth" instead of "creation" — the same value an already-connected fast-path reuse reports, since both hand back an existing grant rather than minting one (see the Connect SDK types reference for the full vocabulary).

Pass several providers in allowed_providers when minting the session. The widget shows the user a provider picker before launching the OAuth flow:

session = await app.create_connect_session(
allowed_providers=["google", "github", "slack"],
user_token=user.jwt,
)

Pass a single provider and Alter is invisible to the end user: no picker, and no Alter-branded page before or after the provider, in every flow. The user is sent straight to that provider’s sign-in. They still see the confirmation screen on the way back, where they approve the connection and — when the application allows it — set their own usage limits; passing direct=True on the session opts out of that screen, the duration choice and the policy-authoring step, and closes the page on delivery instead of showing the user what happened. direct requires exactly one provider. The app’s own grant_policy bounds apply either way. One exception to going straight to the provider: a user who has already connected that provider sees a short approval step instead of the provider sign-in, offering Approve for the existing account or Use a different account; that page carries no Alter branding either. Identity works as for any session: pass the end user’s user_token and the resulting grant belongs to that user (resolved later by their JWT, visible in their wallet); omit it and the grant is app-scoped, owned by the application rather than any user.

Which flow runs is decided by whether open() received a container, and only then by the device, so the first row below applies on a phone just as it does on a desktop:

FlowWhen it appliesWhat the user sees
Inlinecontainer supplied, on any devicePass the session’s single_provider flag (open({ token, container, singleProvider: session.singleProvider })) and call open() from the user’s click: the provider’s sign-in window opens straight away, with no tile to click, and the container shows a plain status line until that window reports back. The application’s page is never navigated, so the outcome arrives through the onSuccess and onError passed to that same call. If the browser blocks the window (no click, or pop-ups disallowed) the widget renders one “Continue with” tile so the user can open it with a click of their own; that tile carries the application’s own logo and legal links when configured, and Cancel, never Alter’s.
PopupNo container, on desktop or tablet landscapeThe popup opens on the provider’s sign-in. When the provider returns, the user confirms the connection and the popup closes with onSuccess; on a direct session there is no confirmation step and it closes as soon as the outcome is delivered. A refusal closes it and fires onError with connect_denied.
RedirectNo container, on a phone or tablet portraitThe page itself goes to the provider’s sign-in and returns to the session’s return URL with the outcome. That navigation destroys the callbacks passed to open(), so the outcome is read on the new page load and the session needs a return URL.

Two things remain outside the widget’s control: the browser’s address bar shows the Alter host briefly around the provider round trip, and the provider’s own consent screen names the OAuth application registered with that provider (an app using its own provider credentials sees its own name there).

session = await app.create_connect_session(
allowed_providers=["quickbooks"],
user_token=user.jwt,
)
# session.single_provider is True; hand it to the frontend with the token.
# Add direct=True above to also skip the end user's confirmation screen.
button.addEventListener("click", () => {
void alterConnect.open({
token: session.sessionToken,
container: document.getElementById("alter-connect")!,
singleProvider: session.singleProvider,
onSuccess: (grants) => { /* one grant */ },
});
});
SymptomLikely causeFix
Popup blockedThe session was fetched inside the click handler, so open() ran after the user-gesture task.Fetch the session before enabling the button, then call open() directly in the click handler.
invalid_tokenSession token expired (default 10 minutes) or was already used.Mint a fresh session per click.
Mobile completion callback never firesFull-page navigation destroyed the page’s callback state.Recover completion by polling the session from the application backend.
Popup completes but onSuccess never firesThe session was minted without allowed_origin and no website_url is configured on the app.Either set allowed_origin per session, or set the full public website_url in app settings; Alter derives its scheme, host, and port.
onError fires with popup_blockedSame as “popup blocked” above.Same fix.
Inline widget shows an error and onError fires with embed_origin_mismatchThe embedding page’s origin is not the session’s allowed_origin (or the app’s Website URL origin), so the hosted page refused to bind the session inside the frame.Mint the session with allowed_origin equal to the exact origin of the page that embeds the widget.
onError fires with embed_load_timeoutThe frame delivered no message within 15 seconds: the browser refused the hosted page, it failed to load, or the Alter host was unreachable.Confirm the host page’s origin equals the session’s allowed_origin and that the page is not itself inside an iframe, then mint a fresh session and retry.
open() rejects with embed_unsupported_originThe host page has no http(s) origin (for example file://), or it is itself rendered inside an iframe, so it can never be the widget’s single allowed parent.Serve the page over http(s) at the session’s allowed_origin, outside any other frame.
open() rejects with invalid_containerThe container element is not attached to the document when open() runs.Mount the element first (in React, open from an effect once the ref is set).
Inline widget: provider window does not openThe browser blocked the window the widget opens from the provider click.Allow pop-ups for the site; the widget shows the same instruction and the click can be retried.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.