How to create widgets
Widgets let you add your own UI to the customer portal — a pet “Puppy Club”
check-in, a size selector, a feedback form — without us building it for you. A
widget is a single JavaScript file that runs in a locked-down sandbox and talks
to the portal through a small SDK (window.gro).
This page covers what a widget can do, the security model, the SDK reference, and how to publish one.
The security model (read this first)
Section titled “The security model (read this first)”The portal holds a live customer session that can cancel subscriptions, change payment methods and read personal data. Your widget code is treated as untrusted and is contained by several independent layers, so it can never reach any of that.
- It runs in a cross-origin sandboxed iframe.
sandbox="allow-scripts"withoutallow-same-origingives your code an opaque origin. It cannot read the portal’s cookies,localStorage, DOM, or session token — they live in a different origin and are physically unreachable. - It has no network access. The frame is served with
Content-Security-Policy: default-src 'none'; connect-src 'none'.fetch,XMLHttpRequest, WebSockets and beacons are all blocked — your code cannot call out or exfiltrate anything. - The host mediates every action. The only way to change data is to request a capability. The host re-checks that your widget was granted that scope, re-checks account settings, asks the customer to confirm, rate-limits, and only then performs the action using its own session.
- The backend re-validates everything. Independently of the host, the server re-verifies the session and re-checks ownership (you can only ever touch the current customer’s own data). The actor on any audit log is forced to the customer — a widget can’t impersonate staff.
The SDK — window.gro
Section titled “The SDK — window.gro”Your bundle runs inside the frame and is handed a global window.gro. Always do
your work inside onReady:
window.gro.onReady(function () { const { subscription, entities, lines } = window.gro.context;
// ...build your UI into document.getElementById("gro-widget-root")...
window.gro.resize(); // tell the host how tall your widget is});Read: window.gro.context
Section titled “Read: window.gro.context”Display-only data the host already loaded for the current subscription. No token, no PII beyond what’s shown here.
| Field | Shape |
|---|---|
subscription | { id, status } |
entities | [{ id, name, entity_type, attributes }] |
lines | [{ id, product_title, variant_title }] |
Also available: window.gro.branding ({ primary_color, font_family, border_radius, logo_url }),
window.gro.scopes (the scopes you were granted), and window.gro.isPreview.
Helpers
Section titled “Helpers”window.gro.onReady(cb)— runcbonce context has arrived.window.gro.resize()— re-measure the iframe height. Call it after every render or layout change.
Write: capabilities
Section titled “Write: capabilities”Capabilities are how you change data. Each returns a Promise that resolves on
success and rejects with an error whose .message is a code — most
importantly "USER_CANCELLED" (the customer declined the confirmation, or you’re
in preview mode). You must be granted the matching scope.
// scope: entity.update — update FLAT attributes on an entityawait window.gro.entity.update(entityId, { attributes: { weight: 13.1 },});
// scope: subscription.activity_log — record a was/now change on the subscription// The `action` is always forced into the `widget_` namespace by the server.await window.gro.activity.log({ action: "widget_puppy_checkin", description: "Weight updated for Fluffy", changes: { weight: { from: 12.4, to: 13.1 } }, metadata: { meals_per_day: "2", notes: "eating well" },});
// scope: media.upload — upload a File, get back a public URL// Resolves to { data: { url } }. The widget can't reach the network itself// (CSP), so the host forwards the bytes and returns the stored URL.const res = await window.gro.media.upload(file); // file is a File objectconst url = res.data.url;// Record the URL in an activity log so it appears in the dashboard:await window.gro.activity.log({ action: "widget_puppy_checkin", metadata: { puppy_club_photo_urls: [url] },});window.gro.entity.get(id) reads an entity from the already-loaded context (no
network).
Capabilities & scopes
Section titled “Capabilities & scopes”Grant a widget only the scopes it actually uses.
| Scope | Lets the widget | Confirmation |
|---|---|---|
entity.update | Update an entity’s flat attributes (e.g. a pet’s weight) | Customer confirms |
subscription.activity_log | Write-only. Record a was/now activity log on the subscription | No prompt (audit side-effect) |
media.upload | Write-only. Upload a photo (gro.media.upload(file) → { data: { url } }) | No prompt (file already chosen) |
Activity logging is deliberately create-only — widgets can write audit
entries but can never read them. Photo upload is likewise write-only: the widget
gets back a public URL but can never list or read stored media. URLs you record
in activity-log metadata (e.g. puppy_club_photo_urls) render as clickable
thumbnails in the dashboard activity feed.
Where a widget appears (destination)
Section titled “Where a widget appears (destination)”Pick a destination when you create the widget:
- An in-page slot on the subscription detail view — e.g. Detail · Left · Top, Detail · Sidebar · Bottom.
- New tab — the widget gets its own tab in the subscription view (the tab label is the widget’s name).
When a widget shows (visibility)
Section titled “When a widget shows (visibility)”A host-side match condition decides whether the widget renders for a given subscription. Your widget code never decides this:
- Always show
- Product title contains
<text>— e.g. show only on “Puppy” plans - Entity type equals
<type>— e.g. only when an entity is adog
A worked example
Section titled “A worked example”A minimal widget that reads the first entity, edits its weight, and logs the change on the subscription:
window.gro.onReady(function () { const root = document.getElementById("gro-widget-root"); const entity = (window.gro.context.entities || [])[0]; if (!entity) { root.textContent = "No profile on this subscription."; window.gro.resize(); return; }
const was = entity.attributes && entity.attributes.weight; root.innerHTML = "<label>Weight (kg)</label>" + '<input id="w" type="number" step="0.1" value="' + (was ?? "") + '" /> ' + '<button id="save">Save</button> <span id="s"></span>';
document.getElementById("save").addEventListener("click", function () { const now = Number(document.getElementById("w").value); const status = document.getElementById("s"); status.textContent = "Saving…";
// keep existing attributes (host replaces them wholesale) + update weight const attributes = Object.assign({}, entity.attributes, { weight: now });
window.gro.entity .update(entity.id, { attributes }) .then(function () { return window.gro.activity .log({ action: "widget_weight_updated", description: "Weight updated for " + entity.name, changes: { weight: { from: was ?? null, to: now } }, }) .catch(function () {}); // activity log is best-effort }) .then(function () { status.textContent = "Saved!"; }) .catch(function (err) { status.textContent = err.message === "USER_CANCELLED" ? "Cancelled" : "Failed"; });
window.gro.resize(); });
window.gro.resize();});Publish it
Section titled “Publish it”- Save your widget as a single
.jsfile. - In the dashboard, go to Widgets → Create widget.
- Upload the bundle, give it a name (this is the tab label for “New tab” widgets), choose a destination, grant the scopes you use, and set the visibility condition.
- Toggle Active and save.
Uploading a new bundle later (via Edit → Replace bundle) creates a new version; only the active version is ever served.
Test it
Section titled “Test it”- Preview — open
/apps/gro/portal/previewon your storefront. Widgets render with sample data, but mutations are disabled (they resolveUSER_CANCELLED), so it’s safe for checking layout and rendering. - Live — open a real subscription that matches your visibility condition and try the full flow, including the confirmation prompt.
Seeing it in the dashboard
Section titled “Seeing it in the dashboard”Anything a widget logs appears on the subscription’s activity feed, attributed
to the customer with source: widget. Was/now changes render inline, and any
image URLs in metadata show as click-to-open thumbnails.
The action filter also gains a Widgets group listing the distinct widget_*
actions your widgets have logged (the widget_ prefix is stripped for the label),
exactly like the Integrations group. It’s data-driven, so an action only appears
there once at least one log with that action exists.
Limitations & notes
Section titled “Limitations & notes”- No nested objects in entity
attributes(flat scalars only) — use activity logmetadata/changesfor structured data. - Photo upload accepts only real PNG/JPEG/WEBP/GIF images (≤ 5 MB); SVG and non-images are rejected.
- The owner’s name isn’t in
gro.context; greet using the entity name and degrade gracefully. - Custom storefront domains may need to be allow-listed for embedding.