Plugin Architecture
How the iframe model, App Bridge protocol, and security sandbox work together.
The Iframe Model
Every Whatalo plugin runs inside a sandboxed <iframe> in the admin. The iframe's src points to your server — your plugin loads there, isolated from the admin's JavaScript context.
The sandbox policy is:
sandbox="allow-scripts allow-forms allow-same-origin"allow-scripts— your JavaScript runs normallyallow-forms— form submissions workallow-same-origin— your plugin can access its own cookies and localStorage (on your domain, not the admin's)
allow-same-origin is safe here because the Same-Origin Policy blocks cross-origin access to the admin's storage. The exception: Whatalo actively rejects plugins whose appUrl shares the same origin as the admin dashboard — that configuration would bypass sandbox isolation.
Modal iframes (opened via bridge.modal.open(...)) use a stricter sandbox — allow-scripts allow-forms only, no allow-same-origin.
The App Bridge Protocol
Communication between your plugin and the admin uses the browser's window.postMessage API. The @whatalo/plugin-sdk/bridge package handles the protocol details so you work with clean React hooks.
There are four message types:
| Message type | Direction | Purpose |
|---|---|---|
whatalo:init | HOST → PLUGIN | Handshake — sends the admin's origin to the plugin |
whatalo:action | PLUGIN → HOST | Plugin requests an action (toast, navigate, resize, billing...) |
whatalo:context | HOST → PLUGIN | Host sends store/user/theme data to the plugin |
whatalo:ack | HOST → PLUGIN | Host confirms an action was received and processed |
Handshake Lifecycle
iframe loads
│
▼
HOST sends whatalo:init { origin: "https://admin.whatalo.com" }
│
▼ (plugin stores parent origin for all future postMessage calls)
PLUGIN sends whatalo:action { action: "ready" }
│
▼
HOST sends whatalo:context { storeId, storeName, user, theme, currentPage, ... }
│
▼
Plugin renders — useWhataloContext().isReady === trueThe whatalo:init handshake is critical for security: without it, the plugin would have to use postMessage("*", ...) which broadcasts to any listener. After receiving whatalo:init, the plugin targets only the admin origin for all outbound messages.
Context Data
Once the handshake completes, your plugin receives a WhataloContext object:
interface WhataloContext {
storeId: string; // Public store identifier (NOT the internal UUID)
storeName: string; // Store display name
user: {
id: string;
name: string;
email: string;
role: "owner" | "admin" | "editor" | "staff" | "viewer";
};
appId: string; // Your plugin's ID from the manifest
currentPage: string; // Active page path (e.g., "settings")
locale: string; // Store locale (e.g., "es", "en")
theme: "light" | "dark"; // Current admin color scheme
initialHeight: number; // Suggested starting height in pixels
orderId?: string; // Set when opened from order context
productId?: string; // Set when opened from product context
}storeId is the public identifier — never the internal database UUID.
Page Routing
There is no client-side router. The admin tells your plugin which page to display by passing currentPage in the context. Your plugin reads this value and renders the matching component:
const context = useWhataloContext();
switch (context.currentPage) {
case "settings":
return <SettingsPage />;
case "dashboard":
default:
return <DashboardPage />;
}The currentPage value matches the path field of the page entry in adminUI.pages in your manifest.
Theme Sync
The admin watches for dark/light mode changes using a MutationObserver on document.documentElement. When the admin's theme changes, it sends an updated whatalo:context with the new theme value. Your plugin receives it, and the useWhataloContext() hook re-renders with the new theme.
The template includes useThemeSync() which applies the theme to the plugin's document root automatically.
Available Actions
Actions are requests your plugin sends to the admin host. Each action is acknowledged with a whatalo:ack message containing { success: boolean, error?: string }. Actions time out after 5 seconds if no ack is received.
| Action | What it does |
|---|---|
toast | Shows a notification banner in the admin (3 per 10s per plugin) |
navigate | Navigates the admin to a path (5 per 10s) |
modal.open | Opens a modal overlay loading a URL from your domain (3 per 10s) |
modal.close | Closes the currently open modal |
resize | Updates the iframe height (30 per 10s) |
billing.* | Billing operations — plans, subscriptions, switches (10 per 10s) |
Rate limits are enforced with a sliding window per action type, synchronized across browser tabs via BroadcastChannel.
Security Model
Origin Validation
The host validates the origin of every inbound message against the plugin's registered appUrl. Messages from any other origin are silently dropped. This prevents other iframes or scripts from impersonating your plugin.
Same-Origin Blocking
If your appUrl shares the same origin as the admin (e.g., both served from the same domain), the admin renders an error and refuses to load the iframe. This is a hard safety rule — it prevents the sandbox from being bypassed.
Navigation Restriction
The navigate action only accepts paths starting with /store/ or /admin/. Plugins cannot navigate the admin to external URLs or to arbitrary admin routes that might expose sensitive UI.
Modal URL Validation
Modal URLs must be:
- Valid absolute URLs (
http:orhttps:only) - From your plugin's origin (same scheme + host + port)
- Not using dangerous protocols (
javascript:,data:,blob:,file:)
Localhost URLs are also allowed in development mode, but only in development.
Rate Limiting
Each action type has a per-plugin rate limit enforced with a sliding 10-second window. Rate limits are also synchronized across tabs using BroadcastChannel so opening your plugin in multiple tabs does not multiply the limit.
Resize and Scroll
Plugins report their content height to the host via the resize action. The admin sets the iframe height to match, then manages scrolling at the page level — which creates a native, seamless scroll feel instead of a scrollbar inside the iframe.
The template's useAutoResize() hook does this automatically using a ResizeObserver on your root element.
Next Steps
- Prerequisites — what you need before starting
- Build Your First Plugin — hands-on tutorial
- App Bridge Reference — full API documentation