Skip to content

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 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" without allow-same-origin gives 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.

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
});

Display-only data the host already loaded for the current subscription. No token, no PII beyond what’s shown here.

FieldShape
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.

  • window.gro.onReady(cb) — run cb once context has arrived.
  • window.gro.resize() — re-measure the iframe height. Call it after every render or layout change.

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 entity
await 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 object
const 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).

Grant a widget only the scopes it actually uses.

ScopeLets the widgetConfirmation
entity.updateUpdate an entity’s flat attributes (e.g. a pet’s weight)Customer confirms
subscription.activity_logWrite-only. Record a was/now activity log on the subscriptionNo prompt (audit side-effect)
media.uploadWrite-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.

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).

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 a dog

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();
});
  1. Save your widget as a single .js file.
  2. In the dashboard, go to Widgets → Create widget.
  3. 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.
  4. Toggle Active and save.

Uploading a new bundle later (via Edit → Replace bundle) creates a new version; only the active version is ever served.

  • Preview — open /apps/gro/portal/preview on your storefront. Widgets render with sample data, but mutations are disabled (they resolve USER_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.

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.

  • No nested objects in entity attributes (flat scalars only) — use activity log metadata/changes for 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.