Skip to content

How to create widgets

Widgets let you add your own UI to the customer portal without waiting for us to build it. A pet “Puppy Club” check in, a “how much have you got left?” slider, a size picker, a feedback form. 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 walks you through building one. For every method, field and error code, see the Widget SDK reference. To have an AI write one for you, see Build widgets with AI.

You want to Use Scope
Show subscription, entity and line details gro.context none
Match the store’s look Theme CSS variables, gro.branding none
Save a value on an entity (e.g. a pet’s weight) gro.entity.update() entity.update
Record what happened on the subscription gro.activity.log() subscription.activity_log
Let the customer upload a photo gro.media.upload() media.upload
Open a link in a new tab (e.g. a review page) a normal <a target="_blank"> link.open
Open the portal’s skip flow gro.trigger.skip() flow.skip
Open the portal’s send now flow gro.trigger.sendNow() flow.ship_now
Open the portal’s reschedule flow gro.trigger.reschedule() flow.reschedule
Open the portal’s pause flow gro.trigger.pause() flow.pause
Open the portal’s resume flow gro.trigger.resume() flow.resume
Re-render when something changes (e.g. after skip) gro.onContextChange() none

A widget can’t read cookies, call your own API, or touch anything outside its frame. Everything goes through window.gro. See the security model for why.

Every widget follows the same shape: wait for onReady, draw into #gro-widget-root, then call resize() so the portal sizes the frame to fit.

window.gro.onReady(function () {
const root = document.getElementById("gro-widget-root");
const { subscription, entities } = window.gro.context;
const name = entities[0] ? entities[0].name : "there";
root.innerHTML =
"<p>Hi " + name + "! Your plan is " + subscription.status + ".</p>";
window.gro.resize();
});

Save it as hello.js, then publish it.

A widget should look like part of the portal, not something pasted in. The SDK gives you every value of the store’s portal theme, so you never hardcode a colour or a radius.

Before your code runs, the frame already matches the portal:

  • Font: the store’s theme font is loaded and set on body.
  • Text: body uses the theme’s text size and body text colour.
  • Background: transparent, so your widget sits on the portal page like the other blocks.

So plain text already looks right. For anything with a background, a border or a button, use the theme variables below.

Every theme value is a CSS variable on your widget’s root, named --gro- plus the theme key with dashes (card_radius becomes --gro-card-radius). Sizes are in pixels, and the title weight is a number like 600. Use them anywhere in your CSS:

CSS variable What it’s for Default
--gro-brand-color Accent: titles, selected states, highlights #111827
--gro-heading-text-color Headings #111827
--gro-body-text-color Body text #374151
--gro-muted-text-color Secondary text: hints, labels, small print #6b7280
--gro-link-color Links #111827
--gro-success-color Success messages and confirmations #166534
--gro-focus-color Focus rings on inputs and buttons #6b7280
--gro-background-color The portal page background. Not set when it’s the store’s own not set
--gro-card-background-color Cards and containers #ffffff
--gro-subtle-background-color Subtle fills: table headers, chips, hover rows #f9fafb
--gro-border-color Borders, dividers and outlines #e5e7eb
--gro-border-width Card and input border width 1px
--gro-card-radius Card corner radius 10px
--gro-primary-button-background-color Main button background #111827
--gro-primary-button-text-color Main button text #ffffff
--gro-secondary-button-background-color Secondary button background #f3f4f6
--gro-secondary-button-text-color Secondary button text #111827
--gro-button-radius Button corner radius 8px
--gro-tab-background-color The bar behind tabs, if your widget has its own tabs #f3f4f6
--gro-tab-active-background-color The selected tab #ffffff
--gro-tab-text-color Tab text #6b7280
--gro-tab-active-text-color Selected tab text #111827
--gro-tab-radius Tab bar corner radius 20px
--gro-tab-font-size Tab text size 14px
--gro-upsells-background-color The add extras card background. Not set when it’s a brand tint not set
--gro-winback-color Accent of the offer shown to cancelled customers #16a34a
--gro-alert-color Payment failed and cancelled message borders, error text #bb0a0a
--gro-paused-color The paused message border #d97706
--gro-reward-badge-color Earned loyalty badges. Not set when they use the brand colour not set
--gro-reward-badge-icon-color The tick on earned badges. Not set when it follows buttons not set
--gro-heading-font-size Card titles 20px
--gro-heading-font-weight Card title weight 600
--gro-base-font-size Body text size 14px
--gro-small-font-size Labels, hints and badges 12px
--gro-button-font-size Button text size 14px
--gro-font-family The theme font’s name, e.g. Barlow, or inherit Barlow
--gro-font-stack The theme font with fallbacks, ready for font-family the font + fallbacks

The defaults are what a store gets before it changes its theme. Always give a fallback in var() for the values that can be “not set”, and it doesn’t hurt on the others:

background: var(--gro-card-background-color, #ffffff);

Most widgets sit in a card, like the portal’s own blocks. Put your styles in a <style> tag from your script (your widget is one file, so CSS travels with it):

window.gro.onReady(function () {
const style = document.createElement("style");
style.textContent = `
.card {
background: var(--gro-card-background-color, #fff);
border: var(--gro-border-width, 1px) solid var(--gro-border-color, #e5e7eb);
border-radius: var(--gro-card-radius, 10px);
padding: 16px;
}
.card h3 {
margin: 0 0 4px;
font-size: var(--gro-heading-font-size, 20px);
font-weight: var(--gro-heading-font-weight, 600);
color: var(--gro-heading-text-color, #111827);
}
.card p {
margin: 0 0 12px;
color: var(--gro-muted-text-color, #6b7280);
}
.button {
font: inherit;
font-size: var(--gro-button-font-size, 14px);
font-weight: 600;
border: none;
cursor: pointer;
padding: 10px 16px;
border-radius: var(--gro-button-radius, 8px);
background: var(--gro-primary-button-background-color, #111827);
color: var(--gro-primary-button-text-color, #fff);
}
.button.secondary {
background: var(--gro-secondary-button-background-color, #f3f4f6);
color: var(--gro-secondary-button-text-color, #111827);
}
.button:focus-visible {
outline: 2px solid var(--gro-focus-color, #6b7280);
outline-offset: 2px;
}
`;
document.head.appendChild(style);
document.getElementById("gro-widget-root").innerHTML = `
<div class="card">
<h3>Puppy Club</h3>
<p>Tell us how your pup is getting on.</p>
<button class="button">Check in</button>
</div>`;
window.gro.resize();
});

The same values are on gro.branding.theme, keyed as in the table without the --gro- prefix and with underscores. Sizes are plain numbers there:

const theme = window.gro.branding.theme;
theme.brand_color; // "#111827"
theme.card_radius; // 10

Use this when you draw with canvas or SVG, or build colours from the theme.

When the merchant changes their theme, the variables update in place, so CSS that uses them restyles by itself. If your script reads theme values, listen for changes too:

window.gro.onBrandingChange(function (branding) {
redrawChart(branding.theme);
});

You’ll see this in the dashboard’s live preview (Storefront → Customer portal → Design), where the merchant edits the theme and your widget follows.

The frame loads the theme’s Google font for you and uses it on body, so you don’t need to. Use font-family: var(--gro-font-stack) (or font: inherit on buttons and inputs) to keep it. When the store uses its own theme’s font (inherit), a widget can’t see it and falls back to system fonts.

You can load another font from Google Fonts with a <link> to fonts.googleapis.com. Other font hosts are blocked.

  • Never hardcode a colour, radius or font. Use the variables with a fallback.
  • Keep the background transparent and put content in a card, like the portal’s other blocks.
  • Use --gro-muted-text-color for secondary text and --gro-border-color for dividers, so light and dark themes both work.
  • Inputs: font: inherit, a --gro-border-color border with --gro-border-width, and --gro-button-radius corners.
  • Call gro.resize() after anything changes your height.

Triggers let your widget start the portal’s own flows: skip, send now, reschedule, pause and resume. This is how you turn a question in your widget into an action.

A trigger never changes the subscription itself. It opens exactly what the portal’s own button opens, so the customer still sees everything you’ve set up:

  • your skip, pause and cancellation reasons
  • your retention playbooks and offers
  • the date picker and slot rules
  • the final confirm step

So a widget can never skip, charge or pause anything the customer didn’t choose.

Triggers must be called from a click (or key press) in your widget, so a widget can’t pop a flow open by itself on page load. Call the trigger straight from your click handler.

Ask how much food is left. If it’s low, offer to send the next order now.

window.gro.onReady(function () {
const root = document.getElementById("gro-widget-root");
root.innerHTML =
"<label>How much food is left?</label>" +
'<input id="left" type="range" min="0" max="100" value="50" />' +
'<div id="action"></div>';
document.getElementById("left").addEventListener("input", function (e) {
const action = document.getElementById("action");
const isLow = Number(e.target.value) < 20;
// Only offer it when the portal would let them send now.
if (!isLow || !window.gro.trigger.can("sendNow")) {
action.innerHTML = "";
window.gro.resize();
return;
}
action.innerHTML = '<button id="send">Running low? Send it now</button>';
document.getElementById("send").addEventListener("click", sendNow);
window.gro.resize();
});
function sendNow() {
window.gro.trigger.sendNow().then(function (result) {
const action = document.getElementById("action");
if (result.outcome === "completed") {
action.textContent = "On its way!";
}
window.gro.resize();
});
}
window.gro.resize();
});

Grant the widget the flow.ship_now scope when you publish it.

Trigger Scope Opens Available when
gro.trigger.skip() flow.skip The skip flow: skip reasons, your skip playbook, then the choice of how long to skip Subscription is active and skipping is allowed
gro.trigger.sendNow() flow.ship_now Send now. With delivery on, the delivery date picker for the soonest dates. With delivery off, the “process order now” screen Active, rescheduling allowed, no order already on its way, and not retrying a failed payment
gro.trigger.reschedule() flow.reschedule The reschedule date picker (the single “change delivery date” picker if you’ve turned that on) Active and rescheduling allowed
gro.trigger.pause() flow.pause The pause flow: pause reasons, your pause playbook, then the pause length Active and pausing allowed
gro.trigger.resume() flow.resume The resume date picker if the old date has passed, otherwise a quick “Resume your subscription?” confirm Paused and resuming allowed

“Allowed” follows your portal settings and the subscription’s state, the same rules that decide whether the portal shows its own button. If the portal would hide the button, the trigger is unavailable too.

Check before you show a button, so you never offer something that can’t run:

if (window.gro.trigger.can("skip")) {
// show your skip button
}
// Or read the full list. Values: "skip", "ship_now", "reschedule", "pause", "resume"
window.gro.context.triggers; // e.g. ["skip", "ship_now", "reschedule", "pause"]

triggers only lists flows your widget has the scope for and that can run right now. It updates live (see Reacting to changes).

Every trigger returns a Promise that resolves when the customer closes the flow:

const result = await window.gro.trigger.skip();
// result = { flow: "skip", outcome: "completed" }
outcome Meaning
completed The customer did it (skipped, sent now, rescheduled, paused or resumed)
saved The customer accepted an offer from your retention playbook instead
dismissed The customer closed the flow without doing anything

A customer closing the flow is not an error, so the Promise resolves with dismissed. It only rejects when the trigger couldn’t open at all:

Error code Why
SCOPE_DENIED The widget wasn’t granted the trigger’s flow.* scope
NOT_AVAILABLE The flow can’t run right now (e.g. skip() on a paused subscription)
FLOW_BUSY Another portal window is already open
NEEDS_CLICK The trigger wasn’t called from a click by the customer
RATE_LIMITED Too many requests in a short time
USER_CANCELLED You’re in preview mode, where nothing can change
window.gro.trigger.pause().catch(function (err) {
if (err.code === "NOT_AVAILABLE") {
showMessage("Pausing isn't available right now.");
}
});

When something changes the subscription (a trigger finishes, or the customer uses a portal button), the portal sends your widget a fresh context. Listen for it to keep your UI current:

window.gro.onReady(function () {
render(window.gro.context);
window.gro.onContextChange(function (context) {
render(context); // e.g. the next delivery date moved after a skip
});
});

gro.context is also updated in place, so reading it later always gives you the latest values.

An entity is whatever the subscription is for: a pet, a person, a plant. You can update its attributes:

await window.gro.entity.update(entityId, {
attributes: Object.assign({}, entity.attributes, { weight: 13.1 }),
});

Attributes are replaced as a whole, so copy the existing ones and change what you need, like above.

Record what happened on the subscription. It shows up on the subscription’s activity feed in the dashboard.

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

Widgets can write logs but never read them.

Let the customer pick a photo, upload it, and get back a public URL:

const res = await window.gro.media.upload(file); // a File from an <input type="file">
const url = res.data.url;
// Attach it to an activity log so it shows in the dashboard:
await window.gro.activity.log({
action: "widget_puppy_checkin",
metadata: { puppy_club_photo_urls: [url] },
});

Only real PNG, JPEG, WEBP or GIF images up to 5 MB are accepted. The server checks the file’s actual bytes, not its name or declared type. SVG (which can carry scripts), PDFs and anything else are rejected. URLs you put in activity log metadata show as clickable thumbnails in the dashboard.

A widget is a block on the portal layout, like the next delivery card or the orders list. A new widget starts at the bottom of the first tab’s sidebar. Move it under Storefront → Customer portal → Design → Layout: drag it to another column, use Move to to send it to another tab, or give it a tab of its own by adding a tab and dropping it in.

Under the next delivery card is a natural spot for trigger widgets like “send now”. See Designing your portal for how tabs and blocks work.

A visibility condition decides whether the widget shows for a subscription. The portal checks it on the server, so a widget that doesn’t match never loads.

Condition Shows when
Always show Every subscription
Product title contains A line’s product title contains your text
Entity type equals An entity has this type (e.g. dog)
Order count at least The customer has received at least this many orders
Order count at most The customer has received at most this many orders
Customer has tag The customer has a matching Shopify tag

For Customer has tag, * matches any text, so partner-* matches partner-acme and partner-uk. Separate several tags with commas to show the widget when the customer has any of them (staff, partner-*). Tags are matched ignoring case and come from Shopify, updating when the customer syncs.

  1. Save your widget as a single .js file.
  2. In the dashboard, go to Storefront → Customer portal → Widgets and click Create widget.
  3. Upload the file, give it a name, grant the scopes it uses, and set the visibility condition.
  4. Toggle Active and save.
  5. Place it on the Design tab, under Layout.

Only grant the scopes the widget actually uses. A request for a scope it wasn’t granted is rejected with SCOPE_DENIED.

Uploading a new file later (Edit → Replace bundle) creates a new version. Only the active version is ever served.

  • Dashboard preview: Storefront → Customer portal → Design shows your widget with sample data and your store’s products. Force widgets is on by default, so every widget shows whatever its condition. Turn it off to check your visibility condition against the sample subscription. Edit the theme to check your widget follows it.
  • Storefront preview: open /apps/gro/portal/preview on your storefront. In both previews nothing can change: every action and trigger rejects with USER_CANCELLED. Good for checking layout.
  • Live: open a real subscription that matches your visibility condition and try the whole thing, including any trigger flows.

Anything a widget logs appears on the subscription’s activity feed, attributed to the customer with source: widget. Was/now changes show inline, and image URLs in metadata show as thumbnails.

The action filter gets a Widgets group listing the widget_* actions your widgets have logged (shown without the widget_ prefix). An action only appears there once at least one log with it exists.

Flows opened by a trigger log exactly what the portal button would (a skip logs a skip, and so on), with your reasons and playbook results.

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 walled off by several independent layers:

  • It runs in a sandboxed iframe with no origin. sandbox="allow-scripts" without allow-same-origin means your code can’t read the portal’s cookies, localStorage, page, or session token. They’re simply out of reach.
  • 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, so nothing can be sent out.
  • The portal handles every action. The only way to change anything is through window.gro. The portal checks your widget was granted the scope, checks the store’s settings, rate limits, asks the customer to confirm where it matters, and only then acts using its own session.
  • Triggers can’t act alone. A trigger only opens the native flow. The customer still makes every choice in your portal’s own UI.
  • The server checks everything again. Separately from the portal, the server re-verifies the session and ownership (a widget can only touch the current customer’s own data). Audit logs are always attributed to the customer, so a widget can’t pose as staff.
  • No nested objects in entity attributes. Use activity log metadata or changes for structured data.
  • Photo upload takes real PNG, JPEG, WEBP or GIF images only, up to 5 MB.
  • Requests are rate limited per widget: a burst of 5, then 1 per second.
  • Only one portal flow can be open at a time (FLOW_BUSY otherwise).
  • Triggers only work straight after a click or key press in your widget (NEEDS_CLICK otherwise). A long await before the trigger can use up that window, so call it first.
  • The customer’s own name isn’t in gro.context. Greet using the entity name and handle it being missing.
  • Custom storefront domains may need allow-listing for embedding.