App Bridge

The App Bridge connects your app to the Stok.ly webapp. Stok.ly shows your app in iframes (a hidden worker, full pages, tabs, sections and dialogs). The bridge lets those frames:

  • receive events from Stok.ly, such as "the user clicked your action" or "the table needs values for your columns", and answer them;
  • send commands to Stok.ly, such as showing a toast, opening a dialog or navigating.

Protocol version: 1.

Loading the SDK

Add the script to every page Stok.ly loads in an iframe (your worker and any page, tab, section or dialog URLs):

<script src="https://assets.stok.ly/developers/lib/app-bridge/v1/app-bridge.min.js"></script>

The script defines one global, StoklyAppBridge. The v1 URL always serves the latest compatible release of protocol version 1.

To test your app against upcoming bridge changes on Stok.ly's dev environment, load the dev track instead: https://assets.dev.stok.ly/developers/lib/app-bridge/v1/app-bridge.min.js. It is built from unreleased changes, so only use it for testing and switch back to the production URL before you publish.

Frames

Each URL in your manifest is loaded in its own iframe. The bridge tells you which kind of frame you are in through bridge.frame:

frameLoaded fromVisibleTypical use
workerworker_urlnoHandling events: actions, bulk actions, column values
pagepages[].content_urlyesA full page at /apps/<appId>/<route>
taba tab extension's content_urlyesA tab on a record view
sectiona section extension's content_urlyesA section on a record view
dialogan action's content_url, or openDialogyesA dialog

Stok.ly loads your worker once per session, in the background, and keeps it running. Events for your actions and columns go to the worker, so declare worker_url whenever your manifest has an action without a content_url, any column, or any hook. Stok.ly loads the worker when the browser is idle and reloads it if your release's worker_url changes. If it fails to load, Stok.ly tries again on the next user action that needs it, or after 60 seconds otherwise.

Visible frames are sandboxed with allow-scripts allow-same-origin allow-forms allow-popups, and the worker with allow-scripts allow-same-origin, so alert and confirm, top-level navigation and downloads don't work. Use toast, openDialog and navigate instead. Frame URLs must be HTTPS (HTTP only on localhost, 127.0.0.1 and [::1]) and on one of your release's origins.

A manifest excerpt:

{
	"worker_url": "https://app.example.com/worker",
	"pages": [
		{ "id": "dashboard", "route": "dashboard", "title": "Dashboard", "content_url": "https://app.example.com/dashboard" },
		{ "id": "settings", "route": "settings", "title": "Settings", "content_url": "https://app.example.com/settings" },
		{ "id": "report", "route": "reports/{id}", "title": "Report", "content_url": "https://app.example.com/report" }
	],
	"extensions": [
		{ "id": "nav", "extension_key": "web.sidebar.stock-inventory", "label": "Example", "page": "dashboard" },
		{ "id": "sync", "extension_key": "web.items.view.actions", "label": "Sync", "icon": "sync", "confirm": true },
		{ "id": "stock-score", "extension_key": "web.items.list.table.columns", "label": "Stock score" }
	]
}

Connecting

const bridge = await StoklyAppBridge.createBridge();

createBridge() waits for Stok.ly to connect and resolves with a bridge. Stok.ly connects when your frame has loaded, and gives up if your frame does not answer within 5 seconds, so:

  • call createBridge() while the page is loading (for example, in a plain script at the end of <body>), not after a long async startup;
  • register your event handlers straight after it resolves.

Calling createBridge() again in the same frame returns the same bridge, until you call close().

Options, all optional:

OptionDefaultMeaning
hostOrigins[]Extra exact origins trusted as the Stok.ly host, for example ["http://localhost:4300"]. Local development only, see Security.
timeoutMs30000How long a command waits for Stok.ly to answer.
initTimeoutMswait foreverReject if Stok.ly has not connected within this time. Useful when the page can also be opened outside Stok.ly.
autoResizetrueKeep your frame's height fitted to your content, see Frame height. Set it to false to size the frame yourself with resize.

The bridge exposes:

MemberDescription
appIdYour app's id, the same one shown in the developer portal and used in its Stok.ly URLs (/apps/<appId>/...).
frame"worker", "page", "tab", "section" or "dialog".
contextIDs describing where the frame is shown, plus extensionId and extensionKey on a tab, section or action dialog frame, for example { itemId, extensionId, extensionKey } on an item tab. Values are strings, numbers, booleans, null or arrays of strings or numbers. A bulk action's dialog also gets the selected recordIds. A dialog opened with openDialog has an empty context. On a page frame, { params, query }, see Pages.
versionThe protocol version, 1.
on(event, handler)Handle an event. Returns a function that removes the handler.
onHook(target, phase, handler)Handle a hook on a core action, see Hooks. Returns a function that removes the handler.
toast, openDialog, closeDialog, navigate, refresh, getSessionToken, setQuery, setTitle, choose, setState, setCreateState, setDirty, resizeCommands, see Command reference.
command(name, payload, { timeoutMs? })Send any command by name. timeoutMs overrides the bridge's timeoutMs for this command, and 0 waits forever.
close()Disconnect. Pending commands reject.

Worker example

https://app.example.com/worker:

<!doctype html>
<html>
<body>
	<script src="https://assets.stok.ly/developers/lib/app-bridge/v1/app-bridge.min.js"></script>
	<script>
		StoklyAppBridge.createBridge().then((bridge) => {
			// A page action (or sub-action) without a content_url.
			bridge.on("page-action.invoked", async ({ extensionId, context }, meta) => {
				if (extensionId !== "sync") {
					throw new Error(`unknown action ${extensionId}`);
				}
				// context.itemId is untrusted input. Your backend must check that the
				// item belongs to the account your OAuth token was granted for.
				await syncItem(context.itemId);
				await bridge.toast("Item synced", "success");
				await bridge.refresh({ eventId: meta.id });
			});

			// A bulk action on a table.
			bridge.on("bulk-action.invoked", async ({ recordIds }) => {
				await bridge.toast(`Queued ${recordIds.length} records`, "info");
			});

			// Values for your columns, one object per row.
			bridge.on("columns.requested", async ({ columnIds, recordIds }) => {
				// Fetch through your backend, which authorises every ID it is given.
				const scores = await fetchScores(recordIds);
				const values = {};
				for (const id of recordIds) {
					values[id] = { "stock-score": scores[id] ?? null };
				}
				return { values };
			});
		});

		async function syncItem(itemId) {
			// Call your backend. It authorises the request and calls the Stok.ly API
			// with your OAuth token; never trust the ID on its own.
		}

		async function fetchScores(recordIds) {
			return {};
		}
	</script>
</body>
</html>

While your handler runs, Stok.ly shows the action as loading. If the handler throws or rejects, the action fails and the user sees an error toast with your app's name and your error message (up to 200 characters). If it doesn't answer in time, the toast says your app didn't respond.

Frame example

A dashboard page that opens another page and a dialog:

<!doctype html>
<html>
<body>
	<button id="settings">Settings</button>
	<button id="import">Import</button>
	<script src="https://assets.stok.ly/developers/lib/app-bridge/v1/app-bridge.min.js"></script>
	<script>
		StoklyAppBridge.createBridge().then((bridge) => {
			document.getElementById("settings").addEventListener("click", () => {
				bridge.navigate({ page: "settings" });
			});
			document.getElementById("import").addEventListener("click", () => {
				bridge.openDialog("https://app.example.com/import", { title: "Import", width: 640 });
			});
		});
	</script>
</body>
</html>

And the dialog at https://app.example.com/import:

StoklyAppBridge.createBridge().then((bridge) => {
	document.getElementById("done").addEventListener("click", async () => {
		await bridge.toast("Import started", "success");
		await bridge.closeDialog();
	});
});

Pages: route params and query

A page is served at /apps/<appId>/<route>. A route is segments separated by /, each either a literal ([a-z0-9-]+) or a param ({name}, where name matches [a-zA-Z][a-zA-Z0-9_]*):

  • the first segment must be a literal, and param names must be unique within a route;
  • accountkey and extensionKey are reserved, in any letter case, and can't be param names;
  • a param matches exactly one non-empty segment, of up to 256 characters once decoded, and can't be . or ..;
  • a literal beats a param in the same position, so reports/new and reports/{id} can coexist, and /apps/<appId>/reports/new opens reports/new;
  • two routes of the same shape, such as reports/{id} and reports/{name}, are an error;
  • a nav extension's page must be a page without params, since a link has no values to fill them with.

Reading params and query

Stok.ly passes the route params and the address bar's query params to your page in two ways:

  • appended to your content_url as query params, so your server can read them from the URL;
  • in bridge.context of the page frame, as { params: { ... }, query: { ... } }, both maps of strings.

For /apps/<appId>/reports/42?tab=summary:

const bridge = await StoklyAppBridge.createBridge();
bridge.context.params.id;   // "42"
bridge.context.query.tab;   // "summary"
new URLSearchParams(location.search).get("id");    // "42" as well
new URLSearchParams(location.search).get("tab");   // "summary" as well

Every other frame (tab, section, action dialog) gets its context IDs and extensionKey appended to its content_url the same way. An array value becomes a repeated key.

When names clash on your frame URL, extensionKey wins, then context IDs, then route params, then your content_url's own query params. An address-bar query key that clashes with any of them, or is reserved, is left out, from both the frame URL and bridge.context.query.

Every visible frame must still load the SDK and call createBridge(): a frame that doesn't connect is replaced by an error row (see Frame height).

Stok.ly doesn't add accountkey to your frame URLs. To know which account the user is working in, read accountkey from a verified session token.

Query limits

Query params, whether they arrive in the address bar or are sent with navigate or setQuery, must follow these limits:

  • keys match ^[a-zA-Z0-9_.-]{1,64}$;
  • values are strings;
  • the whole query, encoded, is at most 2048 characters.

Commands that break them are refused with an error saying why. Query params in the address bar that break them are silently left out. StoklyAppBridge.validateQuery(query) runs the same check and returns { query, errors }: the valid entries, and a message for each one left out.

Linking to a page

await bridge.navigate({ page: "report", params: { id: "42" }, query: { tab: "summary" } });

params must supply exactly the route's params, no more and no fewer. query is only allowed with page, and can't use a reserved name or one of the route's param names.

Updating the address bar

A page can keep its own state in the address bar, so a reload or a shared link comes back to the same view:

const { query } = await bridge.setQuery({ tab: "details" });   // replaces the query
await bridge.setQuery({ sort: "name" }, { merge: true });       // adds to it

setQuery updates the webapp's URL without adding a history entry and without reloading your frame, and resolves with the resulting query. It's only allowed from the page frame currently shown, and is rate limited. merge defaults to false, so the query is replaced. To remove a key, replace the query without it. The same limits apply, and the query can't use a reserved name or one of the page route's param names.

Since your frame isn't reloaded, your frame's own URL and bridge.context.query go stale after setQuery. Read them once, when the page loads, and keep your state in your page from then on. If the user changes the query some other way (for example with Back, or a link to the same page), Stok.ly reloads your frame with the new values.

As with all context, treat params and query values as untrusted input. Anyone can put anything in a link to your page, so validate the values and authorise every ID on your backend.

Setting the page title

By default, the heading above your page and the browser tab title (<title> | Stok.ly) both show the page's title from your manifest. A page that shows one record can name it instead:

await bridge.setTitle("Report 42");   // heading "Report 42", tab "Report 42 | Stok.ly"
await bridge.setTitle("");            // back to the manifest title

Titles are cut to 100 characters, and the title goes back to the manifest's when the user leaves the page. setTitle (like choose) needs SDK 1.3.0, and Stok.ly releases from before it refuse the command with host-error. The CDN's v1 URL always has the latest SDK, so if your app must work with older Stok.ly releases, handle the rejection:

bridge.setTitle(`Report ${id}`).catch(() => {});   // keep the manifest title

Frame height

Your pages, tabs, sections and dialogs grow and shrink with their content, so users never see a second scrollbar inside Stok.ly's page. You don't need to do anything for this: by default the bridge watches your document and, whenever its height changes, tells Stok.ly the new height with the resize command. It sends at most one resize per animation frame, and only when the height has actually changed. Your worker is hidden, so it's never resized.

A few things to know:

  • While your frame loads and connects, Stok.ly shows a compact loading row in its place (a dialog keeps its size). Once connected, until your frame sends its first resize, it gets a default height: 400 pixels for a section or page, 600 for a tab and 60% of the window for a dialog.
  • A frame that doesn't connect within 5 seconds (for example one that doesn't load the SDK, or fails to load) is replaced by a one line message saying your app couldn't be loaded, with a Retry button.
  • Stok.ly keeps every frame between 40 and 20000 pixels tall.
  • If Stok.ly refuses a resize, for example an older Stok.ly release that doesn't support it, the bridge stops resizing and your frame keeps its current height.

Sizing the frame yourself

To control the height yourself, turn automatic resizing off and call resize with a height in CSS pixels:

const bridge = await StoklyAppBridge.createBridge({ autoResize: false });
await bridge.resize(480);

CSS to avoid

The bridge measures your document's own height, so your CSS decides whether that follows your content:

  • Don't give html or body a height or min-height of 100%, or one in vh. Your document would then follow the frame's height instead of your content, so it could never shrink, and could keep growing until it reaches the 20000 pixel limit.
  • Content positioned absolute or fixed, outside the normal flow, isn't measured. Leave room for it in the flow, or size the frame yourself.
  • In a dialog, the dialog's body scrolls when your content is taller than the dialog allows.

Hooks

Hooks let your app take part in Stok.ly's own actions, such as saving or deleting an item. For example, an app with a section on the item view can check the section's inputs when the user clicks the item's Save, and block the save if they're invalid, then store them once the item has saved.

Naming

A hook is named by the full key of a core action plus a phase:

  • before runs before the action, and can cancel it (or skip records of a bulk action);
  • after runs once the action succeeded. It never runs for a failed or cancelled action.

Only actions Stok.ly marks as hookable can be hooked. The extension point reference lists every hookable action and the phases each supports. Most create, save, delete and status actions (cancel, void, approve, close and so on) are hookable, as are the delete and status bulk actions on list pages. Examples:

TargetContextNotes
web.items.view.actions.save{ itemId }before runs once the form is valid and has changes, before anything is written
web.items.view.actions.delete{ itemId }before runs once the user has confirmed
web.purchase-orders.view.actions.cancel{ purchaseOrderId }before runs once the user has confirmed
web.items.create.actions.create{}, and { itemId } in afterthe new item's id is in the after context. See Create pages
web.items.list.table.bulk-actions.delete{}, plus recordIdsbefore gets every selected record. after gets the records that succeeded and failed, and is only sent when at least one succeeded. Records your hook skipped are in neither list
  • The context of a view-page action holds the id of the record, named after it (itemId, purchaseOrderId and so on). Actions on a page with no single record, such as the currencies save, have an empty context.
  • A before hook runs only once the action is going ahead: after the form has validated and after any confirmation the user is asked for. An after hook never runs if the action failed.
  • A few actions that open a dialog to collect input are not hookable yet.
  • Hooks can read and cancel, but can't change what's saved.
  • Pages that both create and edit a record (stock transfers, currencies) have no create hook. Their save hook runs instead, with no id in the context while creating.
  • Duplicating a record (for example a template or a pick rule) runs the same create hooks as creating one, with nothing to tell them apart.
  • While handling a hook, your worker can't navigate, openDialog or choose.
❗️

Hooks only run in the Stok.ly web UI. Changes made through the API, CSV imports, integrations or scheduled rules never trigger them, so don't rely on a before hook as your only validation.

Declare every hook in your manifest. Stok.ly only sends your worker the hooks it declares, so declaring only the phases you handle keeps actions fast. Hooks need a worker_url, since only your worker handles them:

"hooks": [
	{ "target": "web.items.view.actions.save", "phases": ["before", "after"] },
	{ "target": "web.items.list.table.bulk-actions.delete", "phases": ["before"] }
]

Handling hooks

In your worker:

bridge.onHook("web.items.view.actions.save", "before", ({ context, frames }) => {
	const section = frames["notes-section"];
	if (!section || !section.supplierCode) {
		return { cancel: true, message: "Supplier code is required" };
	}
	// returning nothing (or { cancel: false }) lets the save go ahead
});

bridge.onHook("web.items.view.actions.save", "after", async ({ context, frames }) => {
	await saveNotes(context.itemId, frames["notes-section"]);
});

onHook is built on the hook.before and hook.after events, routing each by target, so don't also register those events with on. Registering the same target and phase again replaces the handler. Once you've registered any onHook handler, a hook for a target you have no handler for is answered with no cancel. A worker that never calls onHook fails every hook its manifest declares, and the user sees a warning.

A before handler returns (or resolves to):

FieldMeaning
canceltrue blocks the action. The user sees your app's name and message, for example "Example App: Supplier code is required". If several apps cancel, their messages are combined.
messageShown when cancel is true, collapsed to one line of at most 200 characters. Without one, the user sees "Example App cancelled this action".
skipBulk actions only: { [recordId]: reason }. Those records are left out and never processed, and each shows as an error in the results: "Skipped by Example App: reason". Ids that aren't part of the action are ignored.

An after handler's result is ignored. The user has already seen the action succeed.

Failures never block users

Hooks fail open, so a broken app never stops anyone working:

  • a before handler that throws, rejects or doesn't answer within 5 seconds lets the action go ahead, and the user sees a warning that your app couldn't check it. The 5 seconds include loading your worker if it isn't running yet;
  • an after handler has 30 seconds. If it throws, rejects or times out, the user sees a warning.

Answer before hooks quickly, since the user is waiting.

Frame state

A hook's payload carries frames: the state your app's frames on the current page have published, by extension id. Frames publish it with setState:

// in your section frame
input.addEventListener("input", () => {
	bridge.setState({ supplierCode: input.value });
	bridge.setDirty(true);
});
  • The state is any JSON value of at most 16 KB (UTF-8 encoded JSON). The latest value wins, null clears it, and it's cleared when the frame is destroyed.
  • Only tab, section and action dialog frames can publish state, keyed by their extension's id. Frames that haven't published any are left out of frames.
  • frames only has your own app's frames, and only those on the page the action runs on.
  • Before sending a hook, Stok.ly waits for every setState your frames have already sent to arrive, so a user who types and then clicks Save straight away is safe. Call setState as the user types rather than debouncing it.

Unsaved changes

setDirty(true) tells Stok.ly your frame has unsaved changes. While any app frame on the page is dirty, leaving the page (or closing the tab) asks the user first, just as it does for unsaved changes in Stok.ly's own forms. On the item view, a dirty frame of an app that hooks the Save's after phase also makes Save go ahead (and run its hooks) even when the item itself has no changes.

Once your after hooks for an action have succeeded, Stok.ly clears the dirty flag of your frames on the page. Call setDirty(false) yourself when the user discards their changes. The flag is also cleared when the frame is destroyed.

Choosing records

choose lets the user pick records with Stok.ly's own chooser, so your app needs no list UI or data access just to ask "which order?". Your app must have been granted the entity's read scope on the account. Call it and use the ids with your app's API token:

const { cancelled, records } = await bridge.choose("sale-orders", { multiple: true });
if (!cancelled) {
	await fetch("/my-backend/link", { method: "POST", body: JSON.stringify({ orderIds: records.map((r) => r.id) }) });
}

Pass selected to show records already ticked. From a worker, call it only while handling a page action or bulk action event.

Create pages

Create pages have their own actions (back, next and create, or just create on single-page creates) under <entity>.create.actions, for example web.items.create.actions. Pages with a sections container use the same keys as the view page, so one section shows on both. You can hook the create action of each, with the before and after phases:

"hooks": [
	{ "target": "web.items.create.actions.create", "phases": ["before", "after"] },
	{ "target": "web.item-types.create.actions.create", "phases": ["after"] },
	{ "target": "web.item-attributes.create.actions.create", "phases": ["after"] }
]

A create page's before context has no record id, since the record doesn't exist yet. Its after context has the new record's id, for example { itemId } for web.items.create.actions.create, so that's where to store anything your frames collected for the new record. Sections on a create page get no itemId in their own context either.

A create hook's payload also carries these fields:

PhaseFields
beforestate, and draft when included
afterstate, and record when included
  • state is what your own frames published with setCreateState, a JSON object. Frames on a create page can publish it as the user fills in your section, and your hook reads it when they click Create.
  • draft (the values in the form) and record (the record that was created) are only included when your app's installed scopes include the read scope for what's being created, for example catalogue.items.read for items.
  • Secrets never reach apps: credentials and similar fields (a channel's or courier's specifics, a CSV schedule's password, a customer's secret phrase) are left out of draft and record.
  • The record is the created record, with its id. Its shape can differ from draft: for items, draft is the form's values and record is the item as sent to the API. The id is also in the after context, named after the record, for example context.itemId.
  • Invoice create pages make several invoices at once: draft is { invoices }, and the after hook has no single id.
// in your section frame, on a create page
input.addEventListener("input", () => {
	bridge.setCreateState({ supplierCode: input.value });
});

// in your worker
bridge.onHook("web.items.create.actions.create", "before", ({ state }) => {
	if (!state.supplierCode) {
		return { cancel: true, message: "Supplier code is required" };
	}
});

bridge.onHook("web.items.create.actions.create", "after", async ({ context, state }) => {
	await saveNotes(context.itemId, state);
});

setCreateState merges its keys into the current state, and the state is cleared when the create page closes.

Event reference

Events are sent from Stok.ly to your worker. Stok.ly only sends you events for your own extensions. Register a handler with bridge.on(name, handler). The handler receives (payload, meta), where meta.id is the event id. Whatever it returns (or resolves to) is the response. Stok.ly waits up to 30 seconds.

An event with no handler is answered with the error no handler for <event>. Registering a second handler for the same event replaces the first.

page-action.invoked

The user clicked one of your page actions or sub-actions that has no content_url. If the action has "confirm": true, the user has already confirmed.

{
	extensionId: string;   // your extension's id, e.g. "sync"
	extensionKey: string;  // the container key, e.g. "web.items.view.actions"
	context: object;       // IDs for the current page, e.g. { itemId: "..." }
}

Response: none required.

bulk-action.invoked

The user ran one of your bulk actions on selected table rows.

{
	extensionId: string;
	extensionKey: string;
	context: object;
	recordIds: string[];   // the selected rows
}

Response: none required.

columns.requested

A table showing your columns needs values for the rows on screen. It is sent again when the user pages, filters or refreshes.

{
	extensionKey: string;  // e.g. "web.items.list.table.columns"
	columnIds: string[];   // your column ids on this table
	recordIds: string[];   // the rows shown
	context: object;
}

Response:

{
	values: {
		[recordId: string]: {
			[columnId: string]: string | number | boolean | null;
		};
	};
}

Values are rendered as plain text. A missing row or column shows an empty cell. Answer all your columns in one response. Rows and columns that weren't requested are ignored, strings are cut to 500 characters, and numbers that aren't finite are dropped.

hook.before and hook.after

A hooked core action is about to run (hook.before), or has succeeded (hook.after). Handle them with onHook, see Hooks. Stok.ly waits up to 5 seconds for hook.before and 30 seconds for hook.after.

{
	target: string;             // the core action's full key, e.g. "web.items.view.actions.save"
	context: object;            // the action's context, e.g. { itemId: "..." }
	frames: object;             // { [extensionId]: state } for your frames on the page
	state?: object;             // create actions only: what your frames published with setCreateState
	draft?: unknown;            // create actions, before only: the form values (JSON). Needs the flow's read scope
	record?: unknown;           // create actions, after only: the created record (JSON). Needs the flow's read scope
	recordIds?: string[];       // bulk actions only. In hook.after, the records that succeeded
	failedRecordIds?: string[]; // hook.after of bulk actions only: the records that failed
}

Response to hook.before: { cancel?: boolean, message?: string, skip?: { [recordId]: string } }, or nothing. The response to hook.after is ignored.

Command reference

Commands are sent from any of your frames to Stok.ly. Each returns a promise that resolves when Stok.ly has carried out the command, and rejects if Stok.ly refuses it. Stok.ly checks every command against your app's own manifest.

CommandHelperPayloadResult
toasttoast(message, type?){ message: string, type?: "info" | "success" | "error" }none
openDialogopenDialog(url, { title?, width? }){ url: string, title?: string, width?: number | string }none
closeDialogcloseDialog(){}none
navigatenavigate({ page, params?, query? }) or navigate({ path }){ page: string, params?: object, query?: object } or { path: string }none
refreshrefresh({ eventId? }){ eventId?: string }none
getSessionTokengetSessionToken(){}{ token: string, expiresAt: number }
setQuerysetQuery(query, { merge? }){ query: object, merge?: boolean }{ query: object }
setTitlesetTitle(title){ title: string }none
choosechoose(entity, { multiple?, selected? }){ entity: string, multiple?: boolean, selected?: string[] }{ cancelled: boolean, records: { id: string, label: string }[] }
setStatesetState(state){ state: any }none
setCreateStatesetCreateState(state){ state: object }none
setDirtysetDirty(dirty){ dirty: boolean }none
resizeresize(height){ height: number }none

Rules:

  • toast: type defaults to "info". The message can't be empty and is cut to 300 characters. At most 5 toasts per app every 10 seconds.
  • openDialog: url must be on one of the origins your manifest declares. width as a number is pixels, clamped to 200 to 2000, and as a string is a number (up to 4 digits and 2 decimals) with px, vw, %, rem or em, such as "80vw"; any other string is refused. It defaults to 900 pixels, and the dialog is never wider than 95% of the window. title defaults to your app's name and is cut to 200 characters. Resolves once the dialog is open. A dialog opened this way has an empty context and can't use setState. A worker can only open a dialog while it handles a page action or bulk action event.
  • closeDialog: only allowed from a dialog frame, and closes that dialog.
  • navigate: page must be one of your page ids, with params for exactly its route's params and an optional query, see Linking to a page. path must be an internal Stok.ly path starting with /, and must not be another app's page. Any query or hash in path is dropped, and page, params and query aren't allowed with it. Paths with //, backslashes, control characters, empty, . or .. segments, outlets or matrix params are refused. params that would resolve to one of your other pages' literal routes are refused. A worker can only navigate while it handles a page action or bulk action event, never while it handles a hook. The promise rejects if the navigation is cancelled, for example when the user chooses to stay on a page with unsaved changes.
  • refresh: pass the id of the bulk action event you are handling (meta.id) to refresh the table it came from. For a page action event, the id reloads the current page. The id stays valid for 10 minutes; ids from columns.requested or hook events are refused. Without an id, Stok.ly reloads the current page. At most 1 refresh per app every 3 seconds.
  • getSessionToken: see Session token. expiresAt is in milliseconds since the Unix epoch.
  • setQuery: only allowed from the page frame currently shown, and not while a navigation is in progress. At most 10 per second. See Updating the address bar.
  • setTitle: only allowed from the page frame currently shown. Sets the heading above your page and the browser tab title, which otherwise show the page's manifest title. An empty string goes back to the manifest title. The title is cut to 100 characters and resets when the user leaves the page. The SDK rejects a title that isn't a string with invalid before sending it. Stok.ly releases from before SDK 1.3.0 refuse it, see Setting the page title.
  • choose: opens Stok.ly's record chooser. entity is "sale-orders", "items", "purchase-orders", "suppliers", "locations" or "customers", and your app must have been granted that entity's read scope on the account (sales.orders.read, catalogue.items.read, purchasing.orders.read, catalogue.suppliers.read, inventory.locations.read or customers.customers.read), otherwise it is refused. multiple lets the user pick several (default one), and selected is a list of string ids to show already ticked when the chooser opens. Only the first 100 selected ids are pre-ticked; this doesn't limit how many records the user can pick. Only allowed from a frame, or from a worker while it handles a user event (page action or bulk action). It returns ids and labels only, never record data: read the records with your app's API token. If the user closes the chooser, cancelled is true and records is empty. It has no timeout, since it waits for the user, and only ends when the user answers or the bridge closes (closed). The SDK rejects an unknown entity or a selected that isn't an array with invalid before sending it. Needs SDK 1.3.0, and older Stok.ly releases refuse it. See Choosing records.
  • setState: only allowed from a tab, section or action dialog frame, see Frame state. state must be JSON of at most 16 KB, which the SDK checks before sending. StoklyAppBridge.validateFrameState(state) runs the same check.
  • setCreateState: only allowed from a page, tab, section or dialog frame (not the worker), while a create page is open, see Create pages. state must be a JSON object, which the SDK checks (with the 16 KB limit) before sending. It is shallow-merged into the existing create state, the merged state must stay within 16 KB, and it's cleared when the create page closes.
  • setDirty: only allowed from a page, tab, section or dialog frame. The SDK rejects a dirty that isn't a boolean with invalid before sending it. See Unsaved changes.
  • resize: only allowed from a page, tab, section or dialog frame, see Frame height. height is in CSS pixels, and Stok.ly keeps it between 40 and 20000. The SDK rejects a height that is negative or not a finite number with invalid before sending it. You only need resize if you've set autoResize: false.

Errors and timeouts

Failed commands reject with a StoklyAppBridge.BridgeError, which has a message and a code:

codeMeaning
host-errorStok.ly refused or failed the command, or the SDK couldn't send it (for example a payload that can't be cloned). message says why.
timeoutNo answer within timeoutMs (30 seconds by default). choose never times out.
closedbridge.close() was called.
init-timeoutcreateBridge({ initTimeoutMs }) did not connect in time.
no-windowcreateBridge() was called outside a browser.
invalidThe SDK refused the call without sending it, for example setState with a value that isn't JSON. message says why.

Data access

The bridge carries references, not data. Events and bridge.context contain IDs (itemId, recordIds and so on), never the contents of the page. To read or change data, call the Stok.ly API with your app's OAuth token, from your backend. The API only allows what your app's granted scopes permit.

This keeps your app working when the webapp's screens change, and means your app sees exactly what its scopes allow, wherever it runs.

Security

Only let Stok.ly frame your app

Serve every app URL (your worker, pages, tabs, sections and dialogs) with:

Content-Security-Policy: frame-ancestors https://*.stok.ly

This stops other sites from embedding your app and trying to drive it.

The SDK only trusts Stok.ly

The SDK only connects to a host whose origin is https://stok.ly or an https *.stok.ly subdomain, and silently ignores anyone else. StoklyAppBridge.isTrustedHostOrigin(origin, extraOrigins) exposes the same check.

The hostOrigins option adds exact origins to that list. Use it for local development only (for example createBridge({ hostOrigins: ["http://localhost:4300"] })), and never ship it to production, because any page on that origin could then act as the host.

Treat bridge context as untrusted input

Everything the bridge hands your frames (context, recordIds, route params, query params and so on) comes from the browser and isn't verified. The session token is the verified way to know who the user is and which account they are working in. So:

  • identify the user and account from a session token your backend has verified, never from context or the frame URL;
  • treat context, recordIds, route params, query params and every other ID as untrusted input;
  • authorise all data access on your backend, using your app's OAuth token with the Stok.ly API, which only returns what that account granted your app;
  • never let an ID from the bridge alone decide what your backend reads or changes.

Session token

getSessionToken() returns a short lived, signed token identifying the current user and account. Send it to your backend, which verifies it to find out who is using your frame. Stok.ly only issues a session token while your app is installed on the user's account (an install is complete once your app has exchanged its authorization code, see Installing your app), so getSessionToken() fails before the app is installed and once the account uninstalls it.

Sending it to your backend

const { token } = await bridge.getSessionToken();
const res = await fetch("https://app.example.com/api/me", {
	headers: { Authorization: `Bearer ${token}` },
});

Tokens last 60 seconds, and expiresAt is in milliseconds since the Unix epoch. Call getSessionToken() again for each backend request, or when the token you have is close to expiresAt, rather than storing it. Stok.ly caches the token briefly, so calling it often is fine.

The session token is not a Stok.ly API credential, and the Stok.ly API rejects it. Call the Stok.ly API with your app's OAuth token, as before.

Claims

The token is a JWT signed with RS256, with these claims:

ClaimValue
isshttps://api.stok.ly/apps in production, https://api.dev.stok.ly/apps in dev
audyour app's Client ID, which is its app id
subthe Stok.ly user id
accountkeythe Stok.ly account the user is working in
permissionsstring[]: the short keys of the permissions the signed-in user holds for your app, for example ["approve"]. Empty if they hold none, and every permission your app defines for an account admin
app_idyour app's id
token_usealways "app_session"
jtia unique id for this token
iatwhen the token was issued
expwhen the token expires, 60 seconds after iat

Verifying it

Stok.ly publishes its public keys as a JWKS:

  • Production: https://api.stok.ly/v1/app-extensions/jwks.json, issuer https://api.stok.ly/apps.
  • Dev: https://api.dev.stok.ly/v1/app-extensions/jwks.json, issuer https://api.dev.stok.ly/apps.

On every request, your backend must:

  • verify the signature against the JWKS, using the key that matches the token's kid;
  • check that iss is the Stok.ly issuer;
  • check that aud is your own Client ID;
  • check exp;
  • check that token_use is "app_session".

In Node, with the jose library:

import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL("https://api.stok.ly/v1/app-extensions/jwks.json"));

async function verifySessionToken(token) {
	const { payload } = await jwtVerify(token, jwks, {
		issuer: "https://api.stok.ly/apps",
		audience: process.env.STOKLY_CLIENT_ID,
		algorithms: ["RS256"],
	});
	if (payload.token_use !== "app_session") {
		throw new Error("not a Stok.ly app session token");
	}
	return { userId: payload.sub, accountkey: payload.accountkey, permissions: payload.permissions ?? [] };
}

Enforce permissions in your own API, using the permissions from the verified token, for example by rejecting a request to approve a drop shipment unless permissions.includes("approve").

createRemoteJWKSet caches the keys and picks one by kid, fetching them again when it sees a new kid. When Stok.ly rotates its key, the old and new keys are both published for a while, so rotation needs nothing from you as long as you look keys up by kid rather than pinning one. jwtVerify checks the signature, iss, aud and exp. Reject the request if any check fails.

Protocol details

You do not need this section if you use the SDK. It describes what the SDK does.

  1. When your frame loads, Stok.ly posts { type: "stokly:init", version: 1, appId, frame, context } to it with postMessage, targeted at your frame's origin, and transfers one MessagePort. It repeats this, each time with a new port, until the SDK answers or 5 seconds pass.
  2. The SDK only accepts this message from window.parent, from a trusted host origin (see Security), with version: 1 and exactly one port. It ignores anything else, and any init after the first.
  3. The SDK answers { type: "ready" } on the port. All further messages use the port.
  4. Requests are { id, kind: "event" | "command", name, payload }. Responses are { id, kind: "response", ok: true, result } or { id, kind: "response", ok: false, error }, where error is a message string.
  5. Before a hook, Stok.ly sends the event bridge.flush (with an empty payload) to each of the app's frames on the page, and waits for the responses. The SDK answers it itself, straight away. Since messages on a port arrive in order, every setState the frame sent before then has arrived. Any response counts, so a frame that doesn't know the event is fine too.

Did this page help you?