@yuno-payments/dashboard-embed-sdk
v2.1.0
Published
Lightweight SDK for embedding the Yuno Dashboard via iframe
Downloads
1,696
Maintainers
Keywords
Readme
@yuno-payments/dashboard-embed-sdk
Lightweight SDK for embedding the Yuno Dashboard via iframe. Zero dependencies — uses only DOM APIs.
Installation
npm install @yuno-payments/dashboard-embed-sdkQuick Start
import { initDashboard } from "@yuno-payments/dashboard-embed-sdk";
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token: "your-jwt-token",
theme: {
// Simple: flat tokens apply to both light mode and dark mode;
tokens: { primary: "#134AC3", surface: "#FFFFFF" },
// Per-mode: full control over both light and dark colors
// tokens: {
// light: { primary: "#134AC3", surface: "#FFFFFF" },
// dark: { primary: "#5B7BFF", surface: "#0A0A0A" },
// },
typography: {
fontFamily: "'Inter', sans-serif",
fontUrl: "https://fonts.googleapis.com/css2?family=Inter",
},
mode: "light",
styles: ".yuno-card { border-radius: 8px; }",
},
lang: "en",
path: "/connections",
onReady: () => console.log("Dashboard is ready"),
});API
Singleton helpers
The recommended way to manage the SDK instance:
import {
initDashboard,
getDashboard,
destroyDashboard,
} from "@yuno-payments/dashboard-embed-sdk";
// Create the single dashboard instance
const sdk = initDashboard(config);
// Retrieve the current instance from anywhere
const sdk = getDashboard();
// Destroy the instance and clean up
destroyDashboard();The SDK runs a single instance. Call
initDashboard()once, then drive that instance —navigate()to change pages,setTheme()/setLang()/setToken()for runtime updates. CallinginitDashboard()again while an instance already exists throws: re-initializing reloads the iframe and, withsyncUrlon, the newpathis overridden by the previously synced route. To genuinely tear down and start over, calldestroyDashboard()first.// ❌ Don't re-init to change pages — this throws on the 2nd call: initDashboard({ ...config, path: "/connections" }); initDashboard({ ...config, path: "/checkout-builder" }); // ✅ Init once, then navigate: const sdk = initDashboard({ ...config, path: "/connections" }); sdk.navigate("/checkout-builder"); // ✅ Or explicitly tear down first if you really need a fresh instance: destroyDashboard(); initDashboard({ ...config, path: "/checkout-builder" });
initDashboard(config)
| Option | Type | Required | Description |
|---|---|---|---|
| baseUrl | string | Yes | Dashboard base URL |
| container | HTMLElement | Yes | Element to mount the iframe |
| token | string | No | JWT auth token — sent via PostMessage after the iframe is ready |
| theme | DashboardTheme | No | Initial theme configuration |
| lang | string | No | Language code (default: "en") |
| path | string | No | Initial navigation path (default: "/"). Any query string you pass is stripped — only the SDK sets the iframe query params (embed/theme/lang/test) |
| testMode | boolean | No | When set, the dashboard mounts in test/sandbox mode (true) or live mode (false). Omit to inherit the dashboard's own default. See Test mode |
| onReady | () => void | No | Callback invoked when the dashboard is fully loaded and authenticated |
| onSessionExpired | () => void | No | Callback invoked when the embedded session has expired. The host should re-authenticate and call setToken(newToken) to resume. |
| onNavigationChange | (event: NavigationChangedEvent) => void | No | Fires on every in-app navigation inside the dashboard, delivering the new route via event.path. A cross-cutting notification (like onReady), not a domain event. Optional — with syncUrl on (default) the SDK already keeps the host URL in sync. See Navigation. |
| events | DashboardEvents | No | Notifications the dashboard sends the host, grouped by domain and action — e.g. events.connections.create, events.connections.delete, events.checkout.published, events.checkout.publishAvailabilityChanged, events.customCheckout.* (instance lifecycle + selection). Pure callbacks: providing a handler does not change what the dashboard does. To make the dashboard step aside (e.g. skip its connection success screen) or hand you the save decision, use ui. See Embed events. |
| loading | HTMLElement | No | Custom loading overlay element. If omitted, a default spinner is shown |
| autoHeight | boolean | No | When true, the iframe is resized to match the dashboard content height (no inner scroll). Default false (iframe fills its container at 100%). See Auto height |
| syncUrl | boolean | No | Keep the host's browser URL in sync with the dashboard's in-app route automatically, via the URL hash (e.g. #/payments/abc) — no host code required. Default true; set false to opt out (e.g. if the host owns routing). See Navigation |
| ui | EmbedUi | No | Declarative UI config announced to the dashboard on init — hide/show dashboard UI elements for this embed (e.g. the checkout publish button). See Commands & UI config |
Methods
setTheme(theme)— Update colors, typography, mode, or external stylessetLang(lang)— Change display languagesetToken(token)— Update the auth token via PostMessagenavigate(path)— Navigate to a dashboard routedispatch(command)— Send an imperative command into the dashboard (e.g. publish the open checkout). See Commands & UI configselectCheckout(checkoutCode)— Open the Checkout Builder on a specific custom checkout instance. An empty/blank code is ignored with a console warning (it could never be confirmed). See Selecting a custom checkout instancesetTestMode(enabled)— Toggle test/sandbox mode at runtime via PostMessage. See Test modedestroy()— Remove iframe and clean up event listeners
Types
interface ThemeColors {
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
background: string;
foreground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
destructiveForeground: string;
border: string;
input: string;
ring: string;
surface: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
success: string;
warning: string;
info: string;
}
interface ThemeTypography {
fontFamily: string;
fontUrl: string;
}
interface ModeTokens {
light?: Partial<ThemeColors>;
dark?: Partial<ThemeColors>;
}
interface DashboardTheme {
tokens?: Partial<ThemeColors> | ModeTokens; // flat OR per-mode
typography?: Partial<ThemeTypography>;
mode?: "light" | "dark";
styles?: string; // Custom CSS injected into the dashboard
}
type EmbedEventStatus = "loading" | "success" | "error";
interface ConnectionCreatePayload {
providerId: string;
connectionCode?: string; // Only on "success" — the connection Yuno saved
connectionName?: string; // On every status — the name the user typed
reason?: string; // Only on "error"
}
interface ConnectionCreateEvent {
status: EmbedEventStatus;
payload?: ConnectionCreatePayload;
}
interface ConnectionDeletePayload {
connectionCode: string;
reason?: string; // Only on "error"
}
interface ConnectionDeleteEvent {
status: EmbedEventStatus;
payload?: ConnectionDeletePayload;
}
interface CheckoutPublishedPayload {
code: string; // Custom-checkout instance UUID — present on every status
reason?: string; // On "error": e.g. "user-cancelled" (confirmation dismissed)
[key: string]: unknown; // On "success": the published configuration body
}
interface CheckoutPublishedEvent {
status: EmbedEventStatus;
payload?: CheckoutPublishedPayload;
}
interface CheckoutPublishAvailabilityPayload {
code?: string; // Instance in context; absent on the custom-checkouts list
isPublishAvailable: boolean; // Would publishing do anything right now?
hasUnsavedChanges: boolean; // Blocked because styling edits need a Save first
}
interface CheckoutPublishAvailabilityEvent {
payload: CheckoutPublishAvailabilityPayload;
}
// Custom-checkout INSTANCE lifecycle — see "Custom checkout lifecycle events".
type CustomCheckoutStatus = "PUBLISHED" | "NOT_PUBLISHED" | "ARCHIVED";
type CustomCheckoutLifecycleAction =
| "created"
| "published"
| "unpublished"
| "archived"
| "unarchived"
| "setAsDefault";
interface CustomCheckoutPayload {
// The custom checkout object, as returned by the dashboard API:
code: string; // The instance UUID
name: string;
description?: string; // Optional — the API manages it; the dashboard doesn't use it
status: CustomCheckoutStatus; // Authoritative post-transition instance status
is_default: boolean; // Post-transition default flag
created_at: string; // ISO 8601
updated_at?: string; // ISO 8601 — use as the change timestamp (when present)
last_used_at?: string | null; // Last transaction, null if never used
}
// The event that fired is the envelope's action (events.customCheckout.<action>),
// so it is not repeated in the payload.
// Deprecated alias (pre-1.16.2 name) — will be removed in the next major:
type CustomCheckoutLifecyclePayload = CustomCheckoutPayload;
interface CustomCheckoutLifecycleEvent {
status: EmbedEventStatus; // Transport status — always "success" (fires once)
payload?: CustomCheckoutPayload;
}
// Custom-checkout SELECTION — see "Instance selection events".
interface CustomCheckoutSelectedEvent {
status: EmbedEventStatus; // Transport status — always "success" (fires once)
// Same shape as the lifecycle payload, but `status` is the instance's
// CURRENT status (not a post-transition value).
payload?: CustomCheckoutPayload;
}
interface NavigationChangedEvent {
path: string; // The new in-app route, e.g. "/payments/abc?status=approved"
}
interface DashboardEvents {
connections?: {
create?: (event: ConnectionCreateEvent) => void | Promise<void>;
delete?: (event: ConnectionDeleteEvent) => void | Promise<void>;
};
// Publishing a checkout's CONFIGURATION (styling, payment methods).
checkout?: {
published?: (event: CheckoutPublishedEvent) => void | Promise<void>;
// Publish-button STATE (not a publish) — see "Publish availability".
publishAvailabilityChanged?: (
event: CheckoutPublishAvailabilityEvent,
) => void | Promise<void>;
};
// Custom-checkout INSTANCE lifecycle (create / publish / unpublish / archive /
// unarchive / set-as-default). Distinct from checkout.published above.
customCheckout?: {
created?: (event: CustomCheckoutLifecycleEvent) => void | Promise<void>;
published?: (event: CustomCheckoutLifecycleEvent) => void | Promise<void>;
unpublished?: (event: CustomCheckoutLifecycleEvent) => void | Promise<void>;
archived?: (event: CustomCheckoutLifecycleEvent) => void | Promise<void>;
unarchived?: (event: CustomCheckoutLifecycleEvent) => void | Promise<void>;
setAsDefault?: (event: CustomCheckoutLifecycleEvent) => void | Promise<void>;
// Selection (not lifecycle): the merchant switched the instance in context.
selected?: (event: CustomCheckoutSelectedEvent) => void | Promise<void>;
};
}
// Imperative command sent into the dashboard via dashboard.dispatch().
type EmbedCommand =
| {
domain: "checkout";
action: "publish";
payload?: unknown;
}
| {
domain: "checkout";
action: "select"; // or the selectCheckout() convenience method
payload: { checkout_code: string };
}
| {
domain: "connections";
action: "create.save" | "create.cancel";
payload?: unknown;
};
// Declarative UI config passed via the `ui` option.
interface EmbedUi {
checkout?: {
hidePublishButton?: boolean;
};
connections?: {
hideSuccessScreen?: boolean;
hostControlledCreation?: boolean; // you own the save moment — see the gate below
};
payments?: EmbedPaymentsConfig; // payment / payout detail pages
branding?: EmbedBrandingConfig; // host logo replacing the Yuno brand
menu?: EmbedMenuConfig; // left navigation (sidebar)
topBar?: EmbedTopBarConfig; // top bar + banners
}
// Payment and payout detail pages (/payments/:id, /payouts/:id), for hosts that
// render their own equivalent around the iframe.
interface EmbedPaymentsConfig {
hideSummaryCard?: boolean; // the whole card at the top of the page
hideDetailTabs?: boolean; // the horizontal section-nav tabs below it
}
// Host branding, applied wherever the dashboard would show its own Yuno brand
// (not scoped to one section). `logo` is an image URL that replaces the Yuno
// logo; omit it and the dashboard drops the Yuno brand instead of showing it
// inside the host.
interface EmbedBrandingConfig {
logo?: string;
}
// Left navigation. Hidden by default when embedded; `visible: true` brings it
// back. `sections` makes it a tailored, ordered allowlist.
interface EmbedMenuConfig {
visible?: boolean;
sections?: EmbedMenuSection[];
}
// One tailored section. Default icon dropped; pass `icon` (image URL) to show a
// custom one. `children` is an ordered allowlist of submenu ids (omit = all).
interface EmbedMenuSection {
id: string;
icon?: string;
children?: EmbedMenuSection[];
}
// Top bar. `visible: true` brings it back; its parts are opt-out (each shows
// unless set to false).
interface EmbedTopBarConfig {
visible?: boolean;
sectionName?: boolean;
breadcrumb?: boolean;
testMode?: boolean;
notifications?: boolean;
userMenu?: boolean | EmbedUserMenuConfig;
}
// The account dropdown. Opt-out per entry; `accounts` is the org switcher.
interface EmbedUserMenuConfig {
visible?: boolean;
accounts?: boolean;
profile?: boolean;
security?: boolean;
settings?: boolean;
team?: boolean;
}
onNavigationChangeis a top-level lifecycle callback (likeonReady), not aDashboardEventsentry: navigation is a cross-cutting notification with no domain ownership, so it does not announce a host capability. See Navigation.
Session timeouts
By default, sessions issued by POST /v1/external/authenticate are valid for 24 hours.
You can issue a shorter session by passing timeout_seconds (between 60 and 86400):
curl -X POST https://api.y.uno/v1/external/authenticate \
-H "x-organization-code: <your-org-uuid>" \
-H "Content-Type: application/json" \
-d '{"user_id":"<user-uuid>", "timeout_seconds": 1800}'When the embedded session expires, the iframe paints a "Session expired" overlay
and emits a message to the host. Subscribe via onSessionExpired:
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token: initialToken,
onSessionExpired: async () => {
const newToken = await myBackend.requestEmbedToken({ timeout_seconds: 1800 })
sdk.setToken(newToken)
},
})Embed events
The embedded dashboard sends business events to the host through the events
config, grouped by MFE (domain) and action. Register a handler only for what
you care about:
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token: initialToken,
events: {
connections: {
create: (event) => {
// event.status is "loading" | "success" | "error"
// event.payload is { providerId, connectionName?, connectionCode?, reason? }
if (event.status === "success") {
router.push(`/integrations/${event.payload!.connectionCode}/done`)
}
},
},
checkout: {
published: (event) => {
if (event.status === "success") {
console.log("Checkout published", event.payload)
}
},
},
},
})Events are notifications; ui makes the dashboard step aside
Events are pure callbacks — providing a handler tells the dashboard nothing and
does not change its behavior. Making the dashboard skip its own UI is a
separate, explicit decision via the ui option.
For connection creation the two compose: register
events.connections.create to be notified as the creation progresses, and
set ui: { connections: { hideSuccessScreen: true } } to stop the dashboard
from showing its own success screen so you can route the user yourself. Each
works independently — notify without suppressing, or suppress without notifying.
Controlling connection creation (the create gate)
By default the dashboard persists a connection as soon as the user clicks Save.
If you must run your own processing first — and be able to stop the save when
it fails, so the two systems can't drift apart — set
ui: { connections: { hostControlledCreation: true } }.
The dashboard then defers the save and hands you the decision:
- The user clicks Save. The dashboard emits
events.connections.createwithstatus: "loading"and keeps its form locked and spinning — to the user this looks like a normal save in progress. The payload carries theproviderIdand theconnectionNamethe user typed, so you can label your own record before anything is persisted. - You run your processing.
- Reply with a command — the dashboard is waiting and will not save until you do:
dispatch({ domain: "connections", action: "create.save" })— your side succeeded, persist the connection.dispatch({ domain: "connections", action: "create.cancel" })— your side failed, save nothing and unlock the form so the user can retry.
- The dashboard reports the outcome with
status: "success"(payload adds theconnectionCodeYuno minted) or"error"(payload adds areason). Both repeat theproviderIdandconnectionNamefrom step 1, so you can match the outcome to the record you created without holding state yourself.
initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard"),
ui: { connections: { hostControlledCreation: true } },
events: {
connections: {
create: async (event) => {
if (event.status === "loading") {
try {
await createMyObject(
event.payload.providerId,
event.payload.connectionName,
);
getDashboard().dispatch({
domain: "connections",
action: "create.save",
});
} catch {
getDashboard().dispatch({
domain: "connections",
action: "create.cancel",
});
}
}
if (event.status === "success") {
console.log(
"Connection saved",
event.payload.connectionCode,
event.payload.connectionName,
);
}
},
},
},
});You must always reply. The dashboard has no timeout: if neither
create.savenorcreate.cancelarrives, the form stays locked. Alwayscreate.cancelon failure.
Deleting is reported, not gated: events.connections.delete fires with
loading / success / error (single-row and bulk deletes alike) so you can
keep your side in sync, but you cannot veto a deletion.
Status lifecycle
Handlers fire once per state transition: first status: "loading" when the
action starts, then "success" or "error" once the dashboard's call resolves.
event.payload is present on every status — what it carries grows as the action
progresses (identifying fields from the start, connectionCode once Yuno saved,
reason on failure), so check the field you need rather than assuming.
(Custom-checkout lifecycle events below are the exception — they fire once, on
the completed transition.)
One more exception: if the merchant dismisses the publish confirmation
dialog, checkout.published fires a single terminal "error" with
payload.reason === "user-cancelled" and no preceding "loading" — nothing
was ever sent to the backend. It is the only status sequence that does not start
with "loading", so drive your own button off the terminal statuses rather than
assuming a "loading" always came first.
Publish availability
If you hide the dashboard's own Publish button (ui.checkout.hidePublishButton)
and render your own, events.checkout.publishAvailabilityChanged tells you when
publishing is actually possible, so your button can behave exactly like the
built-in one instead of always looking clickable.
Two events drive the button: publishAvailabilityChanged says whether you can
publish, and published says whether a publish is running. Keep both in state
and render from one place, so they can't overwrite each other.
let availability = null; // last reported state
let isPublishing = false;
function render() {
if (isPublishing) {
myPublishButton.disabled = true;
myPublishButton.textContent = "Publishing…";
return;
}
myPublishButton.textContent = "Publish";
myPublishButton.disabled = !availability?.isPublishAvailable;
myPublishButton.title = availability?.hasUnsavedChanges
? "Save your styling changes before publishing"
: "";
}
initDashboard({
// ...
ui: { checkout: { hidePublishButton: true } },
events: {
checkout: {
publishAvailabilityChanged: ({ payload }) => {
availability = payload;
render();
},
published: ({ status }) => {
isPublishing = status === "loading";
// A successful publish leaves nothing new to publish. Apply that now, so
// the button can't be clicked again before the next availability event.
if (status === "success" && availability) {
availability = { ...availability, isPublishAvailable: false };
}
render();
},
},
},
});It fires once on load — so you have a state to render before the merchant touches anything — and then on every change. It is deduplicated: you get a call when the state actually changes, not on every edit.
isPublishAvailable is false whenever dispatching
{ domain: "checkout", action: "publish" } would do nothing — including when
there is nothing new to publish, when changes still need saving, and on the
custom-checkouts list, where the publish command is ignored. Mirroring it onto
your button therefore cannot leave you with a button that silently does nothing.
hasUnsavedChanges distinguishes the two reasons publishing is unavailable:
true means the merchant has styling / payment-link edits that must be saved
inside the iframe first (there is no command to save on their behalf — only
they can), so it is worth surfacing as a hint rather than a plain disabled
button. false with isPublishAvailable: false simply means nothing changed
since the last publish.
Two details to get right, both handled in the example above:
- A cancelled publish sends no
"loading". Dismissing the confirmation dialog emits a single terminal"error", so only start your spinner on"loading". - A failed publish sends no new availability event — nothing changed. Restore the button from the state you already have instead of waiting for one.
Custom checkout lifecycle events
For white-label hosts that embed the Checkout Builder / Custom Checkouts and render their own controls outside the iframe (e.g. an outer "Publish" button), the dashboard emits an event on every custom-checkout instance lifecycle change, so the host can keep its own button / label / list in sync with what the merchant does inside the iframe.
These are grouped under the customCheckout domain, separate from
checkout.published:
checkout.published— publishing a checkout's configuration (styling, payment methods). Follows theloading → success/errorlifecycle above.event.payload.codecarries the custom-checkout instance UUID on every status (the same identifier ascustomCheckout.*payloads), so you can track which instance was published — including the first publish of a newly provisioned instance, where no other event has told you the id yet. Onsuccessthe payload also carries the published configuration body (name,config, andstylingwhen styling changed);loadingcarries onlycode, anderrorcarriescodeplus an optionalreason("user-cancelled"when the merchant dismissed the confirmation dialog). Type:CheckoutPublishedPayload.events: { checkout: { published: (event) => { if (event.status === "loading") { // The merchant confirmed — the publish is now in flight. outerPublishButton.setLoading(true); return; } outerPublishButton.setLoading(false); if (event.status === "success") { // The instance that was just published — available even on the // very first publish. trackPublishedInstance(event.payload!.code); return; } // status === "error". A dismissed confirmation dialog lands here too, // with no "loading" before it — nothing was sent to the backend. if (event.payload?.reason === "user-cancelled") return; showPublishError(); }, }, },customCheckout.*— the instance lifecycle (the checkout object itself):created,published,unpublished,archived,unarchived,setAsDefault.
Every customCheckout event carries the same payload — the fields of the
custom checkout object — and fires once, on the completed transition
(event.status is always "success"):
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
path: "/checkout-builder",
events: {
customCheckout: {
created: (event) => {
// A new instance exists (PUBLISHED, is_default false).
console.log("Created", event.payload!.code);
},
published: (event) => {
// Instance went live — flip the outer "Publish" button to "Published".
outerPublishButton.setPublished(true);
},
unpublished: (event) => outerPublishButton.setPublished(false),
archived: (event) => refreshMyCheckoutList(),
unarchived: (event) => refreshMyCheckoutList(),
setAsDefault: (event) => {
// event.payload.is_default === true, status === "PUBLISHED".
markAsDefault(event.payload!.code);
},
},
},
});| Event | Transition | Payload status | Payload is_default |
| --- | --- | --- | --- |
| created | new instance | PUBLISHED | false |
| published | NOT_PUBLISHED → PUBLISHED | PUBLISHED | unchanged |
| unpublished | PUBLISHED → NOT_PUBLISHED | NOT_PUBLISHED | unchanged |
| archived | → ARCHIVED | ARCHIVED | false |
| unarchived | ARCHIVED → NOT_PUBLISHED | NOT_PUBLISHED | unchanged |
| setAsDefault | is_default → true (auto-publishes) | PUBLISHED | true |
Payload (CustomCheckoutPayload) is the custom checkout object as
the API returns it, with status set to the authoritative post-transition value.
Same shape for every event:
{
code: "52c2f00b-92af-4ded-8fba-644613464d19", // the instance UUID
name: "Checkout LATAM",
description: "…", // optional — the API manages it; unused by the dashboard
status: "PUBLISHED", // PUBLISHED | NOT_PUBLISHED | ARCHIVED
is_default: true, // exactly one default per account
created_at: "2026-07-08T13:51:42.869114Z",
updated_at: "2026-07-14T16:28:15.275382Z", // optional — use as the change timestamp
last_used_at: "2026-07-15T15:39:14.267177Z", // optional — null if never used
}
// The action (e.g. "setAsDefault") is the envelope's action, not a payload field.
setAsDefaultdemotion: promoting a new default demotes the previous one (is_default → false) server-side. Only one event is emitted — for the newly-promoted instance. The demoted instance does not get its own event; refresh your list off thesetAsDefaultevent if you track the previous default.
Host/iframe contract: the embedded dashboard posts
{ type: "yuno-dashboard:embed-event", domain: "customCheckout", action, status: "success", payload }
to the host on each transition. These events fire only in the embedded
(white-label) context — no postMessage traffic for standard dashboard users.
Instance selection events
customCheckout.selected complements the lifecycle events above: it
reports which instance the merchant is working on, not a state change.
It fires whenever the selected checkout instance changes, including the
initial load:
- on load, when the builder auto-selects the instance to open — so you always learn which instance is in context, even though the merchant may have changed which instance is the account default;
- the merchant picks another instance in the builder's top-menu dropdown;
- the merchant opens one from the Custom Checkouts table ("Open in editor");
- the selection is replaced automatically — an account switch swaps in the new account's default instance, or the open instance is unpublished/archived and the default takes over;
- the host drives the selection with
selectCheckout(code)— honored selects report"success"with the instance payload; a rejected code reports"error"with only the rejected{ code }.
Re-picking the already-open instance does not re-fire.
The payload is the same custom checkout object as the lifecycle events
(code, name, status, is_default, …), except status is the
instance's current status, not a post-transition value.
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
path: "/checkout-builder",
events: {
customCheckout: {
selected: (event) => {
// Render the implementation guide / security preferences for the
// instance now in context.
showImplementationGuide(event.payload!.code);
},
},
},
});Navigation (keeping the host URL in sync)
When the user navigates inside the iframe (e.g. Payment Detail → Routing),
the SDK keeps the host's browser URL in sync automatically — you write no
code. This is on by default (syncUrl: true) and works in both directions via
the URL hash:
- iframe → host: on every in-app route change the SDK writes the route to the
host URL hash, e.g.
https://your-app.com/dashboard#/payments/abc. The address bar stays shareable and bookmarkable, and each navigation is a history entry. - host → iframe (reload / shared link): on load the SDK reads the route from the hash and boots the iframe straight into that view, so a reload or a shared bookmark reopens where the user was.
- host → iframe (back / forward): the SDK listens for the host URL changing (browser back/forward) and steers the dashboard to match.
// That's it — bookmarking, sharing, and back/forward just work:
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token: initialToken,
});Set syncUrl: false to opt out — for example if the host owns routing and
prefers to manage the URL itself. In that case (or alongside the automatic sync)
you can register the optional top-level onNavigationChange callback to run
custom logic on each navigation:
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token: initialToken,
syncUrl: false, // SDK won't touch the host URL
onNavigationChange: (event) => {
// event.path is the dashboard's new in-app route
window.history.replaceState(null, "", `/embed${event.path}`);
},
});onNavigationChange is a pure notification — like onReady it is a top-level
lifecycle callback (not a domain event), it has no status lifecycle, and the
dashboard does not change its own behavior; it just reports the new route via
event.path.
Host/iframe contract: the embedded dashboard posts
{ type: "yuno-dashboard:embed-event", domain: "navigation", action: "changed", payload: { path } }
to the host on each internal navigation; the SDK reflects path into the host URL
hash (when syncUrl) and routes it to the optional callback. Messages are accepted
only from the configured baseUrl origin.
Commands & UI config
The previous sections cover the dashboard handing off to the host. These two channels go the other way — the host drives the dashboard:
- Commands (imperative): trigger an action inside the dashboard, e.g. publish the checkout the user is editing.
- UI config (declarative): hide/show dashboard UI elements for this embed, e.g. hide the dashboard's own publish button so the host can render its own.
Commands — dispatch(command)
Navigate to the relevant view first, then dispatch. A command for an MFE that is
not currently mounted is a no-op (nothing is queued for it), so make sure the
target view is open. The result of the action comes back through the matching
embed event — e.g. publishing reports via events.checkout.published.
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
path: "/checkout/editor/abc",
events: {
checkout: {
published: (event) => {
if (event.status === "success") console.log("Published!", event.payload);
},
},
},
});
// Host's own "Publish" button:
publishBtn.addEventListener("click", () => {
sdk.dispatch({ domain: "checkout", action: "publish" });
});checkout.publish asks for confirmation. It does not publish immediately:
the dashboard opens its own publish confirmation dialog — the same one its
internal Publish button shows — and only publishes once the merchant confirms.
So events.checkout.published "loading" arrives after the confirmation,
not when you dispatch, and if the merchant dismisses the dialog you get a single
"error" with payload.reason === "user-cancelled" instead. Keep this in mind
if you put your button into a pending state on click: wait for "loading" before
showing progress, and clear it on either terminal status. Dispatching this
command from an unattended/scripted flow will simply wait for a human.
Selecting a custom checkout instance — selectCheckout(code)
Breaking in 2.0.0:
CustomCheckoutSelectedEvent.payloadis nowCustomCheckoutPayload | Pick<CustomCheckoutPayload, "code">— onstatus: "error"(a rejected select) only the rejectedcodeis known. Existingselectedhandlers that readpayload.name/payload.statusmust narrow onevent.statusfirst, as shown below. Runtime behaviour for hosts that never callselectCheckoutis unchanged.
When the host manages its own instance list outside the iframe (white-label),
the builder normally opens on the account default. selectCheckout opens
it on a specific instance instead — the inbound counterpart of the
customCheckout.selected event:
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
path: "/checkout-builder",
events: {
customCheckout: {
selected: (event) => {
if (event.status === "error") {
// The code was not honored (unknown / not openable for the
// authenticated account): the builder stays on / falls back to the
// default, and event.payload carries only the rejected { code }.
console.warn("select rejected", event.payload?.code);
return;
}
showImplementationGuide(event.payload!.code);
},
},
},
});
// On load — dispatch right after init; the builder replays the last select
// once it mounts:
sdk.selectCheckout("cc_123");
// At runtime — the user picks another instance in the host's own list:
instanceList.addEventListener("change", (e) => sdk.selectCheckout(e.value));Unlike other commands, checkout.select is not lost when the builder view
is still loading: the dashboard replays the last select once the builder
mounts, so dispatching immediately after initDashboard works. The selection
is confirmed (or rejected) through events.customCheckout.selected either way.
The code must belong to the authenticated account — a foreign or unknown
code never selects anything and reports status "error".
UI config — ui
Announced once to the dashboard when it becomes ready. Use it to suppress dashboard UI that the host replaces with its own:
initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
ui: {
checkout: {
hidePublishButton: true, // host renders its own publish button
},
connections: {
hideSuccessScreen: true, // host owns the post-create moment
},
},
});Hiding the publish button does not hide the publish confirmation dialog: when
your own button dispatches checkout.publish the
dashboard still asks the merchant to confirm, and reports a dismissal as
checkout.published "error" with payload.reason === "user-cancelled".
Payment detail pages. The detail page for a payment (/payments/:id) and a
payout (/payouts/:id) opens with a card summarising the transaction, and a row
of horizontal tabs that scroll to each section below it. Hide either when your
own page already shows that information:
initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
ui: {
payments: {
hideSummaryCard: true, // the card at the top of the page
hideDetailTabs: true, // the horizontal section-nav tabs
},
},
});hideSummaryCard removes the entire card: the amount, the action buttons
(capture / cancel / refund / download PDF), the status badges, and the
id / date / country grid — the page then starts at the timeline. If you hide it,
your own UI has to provide any of those actions you still want the user to have.
Both flags default to off, and both apply to payments and payouts alike.
Menu & top bar (white-label). Embedded mode hides the dashboard's own navigation menu and top bar by default — the host owns the frame. To bring either back (e.g. a white-label host that wants Yuno's navigation), opt it in:
initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
ui: {
menu: { visible: true }, // show the left navigation (sidebar)
topBar: { visible: true }, // show the top bar + banners
},
});Both are opt-in: omit them (or set visible: false) to keep the current
no-chrome embed.
Tailored menu & top bar (white-label). Go further and render only the
sections a white-label customer needs, in your order, with the top-bar parts you
want. The menu is an ordered allowlist (icons dropped unless you pass an image
URL); the top-bar parts are opt-out (each shows unless set to false):
initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
ui: {
menu: {
visible: true,
sections: [
{ id: "home", icon: "https://cdn.acme.com/home.svg" },
{ id: "connections", children: [{ id: "providers" }] }, // only this submenu
{ id: "routing" },
],
},
topBar: {
visible: true,
testMode: false, // hide these parts…
notifications: false,
userMenu: { accounts: false, team: false }, // …and these account entries
// sectionName / breadcrumb stay on (opt-out)
},
},
});Menu section ids: home, insights, operations, reconciliations,
connections, routing, checkout-builder, installments, risk-conditions,
marketplace, subscriptions, payment-links, payments-concierge, nova,
api-reference, audit-logs. A configured id the customer can't access
(permission / feature flag) is skipped.
Branding (host logo). Wherever the dashboard would show its own Yuno brand inside the frame (e.g. the connection create/edit screen shows the provider logo linked to the Yuno logo), pass your own logo to replace it:
initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
ui: {
branding: {
logo: "https://cdn.acme.com/logo.svg", // replaces the Yuno logo
},
},
});Omit branding.logo and, when embedded, the dashboard drops the Yuno brand
entirely (logo and its connector icon) rather than showing it inside the host.
Host/iframe contract: dispatch() posts
{ type: "yuno-dashboard:command", domain, action, payload } to the iframe; the ui
config is announced as { type: "yuno-dashboard:ui", ui } on ready. The dashboard
validates both come from the host (parent) origin.
Test mode
The dashboard can run in test/sandbox mode or live mode. Set it at mount time
via the testMode option, or toggle it at runtime via setTestMode(enabled):
const sdk = initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
path: "/connections", // clean route — no query string
testMode: false, // mount in live mode
});
// Later — flip to test mode without remounting
sdk.setTestMode(true);Do not pass test mode through path. The SDK builds the iframe URL by appending its
own query string (embed, theme, lang, and test when testMode is set) to the route:
${baseUrl}${path}?embed=true&…. A path that already carries a query (e.g.
"/connections?test=false") produces a malformed double-? URL that swallows embed and
breaks the embed (the dashboard never mounts). Always pass a query-free path and use the
testMode option / setTestMode() method instead.
Host/iframe contract: when testMode is provided, the SDK appends test=<bool> to the
initial iframe URL — the embedded dashboard reads it from its own URL on load. For the runtime
toggle, setTestMode(enabled) sends postMessage({ action: "setTestMode", testMode }) (accepted
only from the configured baseUrl origin); the embedded dashboard must handle that action to
switch mode without a reload.
Loading overlay
A loading overlay covers the iframe while initialization completes. You can provide a custom element via the loading config option, or the SDK renders a default spinner. The overlay fades out automatically (300ms transition) once the dashboard is authenticated and ready.
Auto height
By default the iframe fills its container (height: 100%) and the dashboard content
scrolls inside it. Pass autoHeight: true to size the iframe to the dashboard's content
height instead, so the whole host page scrolls naturally with no inner scrollbar:
initDashboard({
baseUrl: "https://dashboard.y.uno",
container: document.getElementById("dashboard")!,
token,
autoHeight: true,
});Host/iframe contract: when autoHeight is enabled, the embedded dashboard reports its
content height to the host via postMessage({ action: "resize", height }) whenever the
content resizes; the SDK applies that height to the iframe and its wrapper. Messages are
accepted only from the configured baseUrl origin. Your container must allow vertical growth
(avoid a fixed height / overflow: hidden) for the effect to be visible.
Overlays (modals & drawers)
An embedded overlay is positioned relative to its iframe, not the host page — so a
modal would appear centered to the iframe panel, not the browser viewport. To fix this,
the embedded dashboard signals the SDK when an overlay opens or closes
(embed-event with domain: "ui", action: "overlay-open" | "overlay-close"), and the
SDK expands the iframe to the full viewport (position: fixed; inset: 0, top
z-index) for as long as any overlay is open, then restores it. This is automatic — no
host code required. Signals are ref-counted, so stacked overlays are handled correctly.
While expanded the iframe is transparent, and the SDK captures the iframe's prior
panel position (getBoundingClientRect) and sends it to the dashboard as an offset
(postMessage({ action: "overlay-mode", active, offset })). The dashboard uses that
offset to leave the host's chrome regions unpainted, so your surrounding UI stays visible
through the iframe while the overlay is centered to the page. No host code required.
Host/iframe contract: while an overlay is open the iframe covers the whole viewport
(transparent) at a maximal z-index. Host chrome behind the iframe is visible but not
interactive during the overlay (the iframe captures pointer events) — expected for a
modal. Messages are accepted only from the configured baseUrl origin.
Checkout preview (initCheckoutPreview)
A read-only preview of a published custom checkout, in its own iframe: the payment method list and the form as your merchant's buyers will see them, with the styling, payment-method visibility and required fields of a given checkout code.
Nothing behind it can take a payment. There is no checkout_session and no transaction
token; the Pay / Save CTA is inert, and the frame makes no transaction call.
import { initCheckoutPreview } from '@yuno-payments/dashboard-embed-sdk'
const preview = initCheckoutPreview({
baseUrl: 'https://dashboard.y.uno',
container: document.getElementById('preview')!,
token: dashboardJwt,
checkoutCode: 'cc_9f2a...', // the code from checkout.published / customCheckout.*
accountCode: 'acc_1b7c...', // the account that checkout belongs to (see below)
testMode: false, // live data — the frame defaults to TEST
platform: 'desktop', // 'desktop' | 'mobile' — default 'desktop'
onHeight: ({ height }) => {
// Position your own content directly under the frame.
brandingEl.style.marginTop = `${height}px`
},
events: {
checkoutPreview: {
loaded: ({ status, payload }) => {
if (status === 'error') hidePanel(payload.reason)
},
},
},
})Not a second dashboard
initDashboard is a singleton and throws on a second live instance — a guard worth
keeping, since re-initializing on every navigation reloads the iframe and loses the
route. A preview is a different thing, so it has its own entry point:
initCheckoutPreviewis not a singleton. Mount as many previews as you need, alongside a running dashboard instance.getDashboard()keeps meaning "the dashboard".- Each instance only acts on messages from its own iframe, so two frames on the same origin never receive each other's events or resize each other.
Your preview frame needs its own token. It is a separate iframe with its own session —
a token set on your dashboard instance does not reach it. Pass token on init, or call
preview.setToken(jwt).
Multi-account users: pass accountCode
A checkout code is resolved account-scoped — the same code means nothing outside the
account that owns it. And a preview frame cannot inherit which account the user is
working in: an iframe is a separate browsing context with partitioned storage, so it
starts empty even when the same user has a dashboard tab open on the right account. Left
to itself the frame falls back to the user's principal account, and a checkoutCode
living in any other account fails to resolve — the frame reports
checkoutPreview.loaded with status: 'error'.
So whenever your users can have more than one account, pass the one the checkout belongs to:
initCheckoutPreview({
baseUrl: 'https://dashboard.y.uno',
container: el,
token: dashboardJwt,
checkoutCode: 'cc_9f2a...',
accountCode: 'acc_1b7c...',
})The dashboard applies it on load, before the first account-scoped request, and only if the authenticated user actually has access to that account — a code they cannot reach is ignored rather than trusted, and the frame resolves the account on its own as before. Single-account users can leave it out.
accountCode and testMode work together. An account has two environment codes, and
the frame resolves checkoutCode with the one matching its mode. An embedded frame
defaults to test, so a checkout published in live needs testMode: false — otherwise
the lookup runs against the account's testing environment and comes back not-found.
Both are applied on load, so changing either means mounting a new preview.
Options
| Option | Type | Required | Description |
| --- | --- | --- | --- |
| baseUrl | string | Yes | Dashboard origin, e.g. https://dashboard.y.uno. |
| container | HTMLElement | Yes | Element the preview iframe is mounted into. |
| checkoutCode | string | Yes | The custom checkout to render. The only configuration input — the preview always shows that code's published configuration, and individual values cannot be overridden. |
| token | string | No | Dashboard JWT. Required to render; pass it here or via setToken(). |
| accountCode | string | No | The account checkoutCode belongs to. Pass it whenever the user has more than one account — see below. Accepts the account's identity code or either environment code. |
| platform | 'desktop' \| 'mobile' | No | Which form width to render. Default 'desktop'. |
| testMode | boolean | No | Which data the frame runs on. Defaults to TEST (the embedded default) — pass false for a live checkout, or the frame looks for the code in the account's testing environment and reports not-found. |
| autoHeight | boolean | No | Size the iframe to the rendered preview. Default true (unlike initDashboard) — a preview has no chrome of its own. |
| onHeight | (e: CheckoutPreviewHeightEvent) => void | No | The rendered height. See below. |
| events.checkoutPreview.loaded | (e: CheckoutPreviewLoadedEvent) => void | No | Outcome of resolving the code. |
| lang, theme, loading, onReady, onSessionExpired | — | No | Same meaning as on initDashboard. |
Methods
setToken(jwt)— supply or refresh the preview's dashboard token.setCheckoutCode(code)— point the frame at another published checkout. Counts as a new rendered configuration, soloadedandonHeighteach fire again.setPlatform('desktop' | 'mobile')— switch the rendered width.setLang(lang)/setTheme(theme)— as on a dashboard instance.destroy()— tear down the iframe and its listeners.isDestroyed()— whetherdestroy()has run.
Height (onHeight)
Fires once per rendered configuration, right after the preview paints — so you can butt your own content against the frame without a gap. The value is the preview's own height in CSS pixels, including its internal spacing and excluding any dashboard chrome.
It is not a resize stream. Interacting with the form (opening a payment method,
expanding the condensed view) changes the height without re-emitting. With autoHeight on
(the default) the iframe still resizes itself, so if you lay out in normal document flow
you do not need onHeight at all — it is for hosts that position content explicitly.
Calling setCheckoutCode() or setPlatform() produces a new configuration, and a new
emission.
It fires whether or not autoHeight is on.
Outcome (events.checkoutPreview.loaded)
loading on mount, then exactly one terminal status. The event is a discriminated
union on status, so if (status === 'error') narrows payload.reason to a defined
value — no non-null assertion needed. On error the payload carries a reason:
| reason | Meaning |
| --- | --- |
| not-found | No checkout with that code in the authenticated account. An unknown code and another account's code are indistinguishable — the lookup is account-scoped by design. |
| not-published | The checkout exists but has never been published, so it has no published configuration to show. |
| no-payment-methods | Published, but every payment method in it is switched off. |
The frame renders a readable state for each of these — it never goes blank — so handling the event is optional, and only needed if you want to hide or relabel your own chrome.
Limitations
The preview is the same mock-up the Checkout Builder shows, so it inherits the same limits:
- Generic layout. Every payment method renders the same form shape; it does not reproduce each method's real flow.
- No interactive states. No confirmations, popups or modals triggered by user action.
- No condition filters. The Checkout Builder's country / currency / amount preview filters are not applied — the preview always shows the configuration as published.
- Form only. The payment method list and its form, not a full payment-link page (no merchant logo, amount or order summary).
Types
type CheckoutPreviewPlatform = 'desktop' | 'mobile'
type CheckoutPreviewErrorReason =
| 'not-found'
| 'not-published'
| 'no-payment-methods'
interface CheckoutPreviewPendingPayload {
code: string
}
interface CheckoutPreviewErrorPayload {
code: string
reason: CheckoutPreviewErrorReason // always present on an error
}
type CheckoutPreviewLoadedPayload =
| CheckoutPreviewPendingPayload
| CheckoutPreviewErrorPayload
// Discriminated on `status`: narrowing to 'error' gives you a defined
// `payload.reason`, and `reason` does not exist on the other branch.
type CheckoutPreviewLoadedEvent =
| { status: 'loading' | 'success'; payload: CheckoutPreviewPendingPayload }
| { status: 'error'; payload: CheckoutPreviewErrorPayload }
interface CheckoutPreviewHeightEvent {
code: string
height: number
}Migrating from 0.x to 1.0
1.0.0 drops the Yuno brand prefix from the public API so the SDK reads cleanly
in white-labelled integrations. The old names were removed — there is no
compatibility shim, so update every import and call site:
| 0.x (removed) | 1.0 (new) |
|---|---|
| initYunoDashboard(config) | initDashboard(config) |
| getYunoDashboard() | getDashboard() |
| destroyYunoDashboard() | destroyDashboard() |
| YunoDashboard (class) | Dashboard |
| YunoDashboardConfig (type) | DashboardConfig |
- import { initYunoDashboard, getYunoDashboard, destroyYunoDashboard } from "@yuno-payments/dashboard-embed-sdk";
- import type { YunoDashboardConfig } from "@yuno-payments/dashboard-embed-sdk";
+ import { initDashboard, getDashboard, destroyDashboard } from "@yuno-payments/dashboard-embed-sdk";
+ import type { DashboardConfig } from "@yuno-payments/dashboard-embed-sdk";
- const sdk = initYunoDashboard(config);
+ const sdk = initDashboard(config);Behavior, config options, methods, callbacks, and the PostMessage host/iframe contract are unchanged — this release renames the API surface only.
Development
npm install
npm run build # Build with tsup
npm run dev # Watch mode
npm run type-check # TypeScript check