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.
What a widget can do
Section titled “What a widget can do”| 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.
Your first widget
Section titled “Your first widget”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.
Styling your widgets
Section titled “Styling your widgets”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.
What you get for free
Section titled “What you get for free”Before your code runs, the frame already matches the portal:
- Font: the store’s theme font is loaded and set on
body. - Text:
bodyuses 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.
Theme variables
Section titled “Theme variables”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);A card that matches the portal
Section titled “A card that matches the portal”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();});Reading theme values in JavaScript
Section titled “Reading theme values in JavaScript”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; // 10Use this when you draw with canvas or SVG, or build colours from the theme.
Live updates
Section titled “Live updates”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.
Styling rules of thumb
Section titled “Styling rules of thumb”- 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-colorfor secondary text and--gro-border-colorfor dividers, so light and dark themes both work. - Inputs:
font: inherit, a--gro-border-colorborder with--gro-border-width, and--gro-button-radiuscorners. - Call
gro.resize()after anything changes your height.
Hooking into portal actions (triggers)
Section titled “Hooking into portal actions (triggers)”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.
Example: “Running low? Send it now”
Section titled “Example: “Running low? Send it now””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.
What each trigger opens
Section titled “What each trigger opens”| 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.
Knowing what’s available
Section titled “Knowing what’s available”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).
Knowing how it ended
Section titled “Knowing how it ended”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."); }});Reacting to changes
Section titled “Reacting to changes”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.
Saving data
Section titled “Saving data”Entities
Section titled “Entities”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.
Activity logs
Section titled “Activity logs”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.
Photos
Section titled “Photos”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.
Where a widget appears
Section titled “Where a widget appears”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.
When a widget shows
Section titled “When a widget shows”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.
Publish it
Section titled “Publish it”- Save your widget as a single
.jsfile. - In the dashboard, go to Storefront → Customer portal → Widgets and click Create widget.
- Upload the file, give it a name, grant the scopes it uses, and set the visibility condition.
- Toggle Active and save.
- 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.
Test it
Section titled “Test it”- 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/previewon your storefront. In both previews nothing can change: every action and trigger rejects withUSER_CANCELLED. Good for checking layout. - Live: open a real subscription that matches your visibility condition and try the whole thing, including any trigger flows.
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 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 security model
Section titled “The security model”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"withoutallow-same-originmeans 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.
Limits
Section titled “Limits”- No nested objects in entity
attributes. Use activity logmetadataorchangesfor 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_BUSYotherwise). - Triggers only work straight after a click or key press in your widget
(
NEEDS_CLICKotherwise). A longawaitbefore 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.