Plugin API Reference
Plugins in ShopsWired are written in modern JavaScript (ES2015+) and run server-side on ShopScript — ShopsWired's custom synchronous JavaScript runtime that powers every customization script (plugin hooks, routes, widgets, scheduled jobs). Use const/let, arrow functions, template literals, destructuring, optional chaining, and for…of freely — the examples in this guide do. Plugins can hook into storefront events, modify data, schedule tasks, define routes, split code across files with require(), and interact with the database via the provided Bridges.
Looking for a "how do I build X?" walkthrough? This guide is the reference for individual surfaces (bridges, hooks, routes, widgets). Goal-oriented, end-to-end recipes that compose them — order attribution, sales-rep assisted carts, bulk CSV import, abandoned-order recovery — live in Recipes.md.
Directory Structure
Plugins are located in backend/plugins/<plugin_id>/.
A typical plugin looks like this:
backend/plugins/my-plugin/
├── manifest.json
├── hooks.js # Contains event handlers
├── render.js # Storefront rendering hooks
├── bridges.js # Bridge scripts
└── lib/
└── feed.js # Helper module pulled in with require('./lib/feed')
Splitting code across files (require)
Plugins can split their JavaScript across multiple files using Node-style require(). Paths resolve relative to the requiring file and are sandboxed to the plugin's own directory — absolute paths and ../ escapes are rejected, and there is no node_modules resolution (the engine ships no npm packages).
// cron.js
const feed = require('./lib/feed');
module.exports.run = function (ctx) {
const items = feed.fetchLatest();
console.log('fetched', items.length, 'items');
};
// lib/feed.js
module.exports.fetchLatest = function () {
return fetch('https://api.example.com/feed').json();
};
Compiled modules are cached per plugin and re-used across runs — require('./lib/feed') parses and compiles lib/feed.js exactly once until the source changes. Saving a file override (or reinstalling the plugin) invalidates the cache automatically, so the next run picks up your edit.
require() is available in scheduled scripts, durable bg tasks (sw.task.bg), route handlers, widget scripts, hook scripts, and sw.task.run runs. It is not available inside storefront template render scripts (those share a single render context across plugins).
The manifest.json
The manifest defines the plugin identity, the scripts to execute, and configurable settings.
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"scripts": [
{
"path": "hooks.js"
},
{
"path": "render.js",
"routes": ["/product/*", "/"]
},
{
"path": "cron.js",
"schedule": "* * * * *"
}
],
"settings": [
{
"key": "api_key",
"type": "text",
"label": "API Key"
}
]
}
routes: (Optional) Restricts a script to only run on matching storefront routes.templates: (Optional) Restricts a script to only run when rendering specific template names.dataloaders: (Optional) Restricts a script to only run on pages using specific data loaders (e.g.["checkout"],["product"],["cart"]).schedule: (Optional) Cron expression to run a script on a schedule. A scheduled script can also be triggered on demand by the shop from the plugin's Status panel ("Run now"), independently of its cadence — even forprivate-source plugins where the merchant can't see the code. Write scheduled scripts to be idempotent and safe to run off-schedule. Only declared scheduled scripts (a plain script with aschedule, or one inheriting the plugin-level schedule) are runnable this way; hook/route/widget scripts and internallibfiles are not.plans: (Optional) Ordered list of plan keys for tiered pricing, e.g.["free", "pro", "biz"]. Prices and features are set at publish time; the active key is exposed to scripts asctx.plan. See Plugin Plans.secrets: (Optional, top-level) Declares the credentials the plugin expects so they appear as labeled entries in the Secrets panel. See Declaring secrets in the manifest.
Hook auto-detection. Hook names are auto-detected from each script's module.exports — you don't list them. If a registered file (one named in scripts, or a widget's source.script) has a syntax error so it would register no hooks, install / update / activation fails with the offending file and error rather than silently activating with a partial set of hooks — fix the reported file and re-push. A syntax error in an unregistered .js file (a require()'d helper, a browser script served via a <script> tag) is not fatal: those aren't hook entry points. Static assets are never scanned. (Dev sync can't hard-fail a push, so it instead reports the error on the CLI log stream and in the admin log viewer.)
Action scripts ("type": "action")
An action is an admin-triggered script with no schedule — a button the shop runs on demand from the plugin's Status panel. Unlike "Run now" on a scheduled script, an action can prompt for parameters before it runs, and the entered values are exposed to the script as ctx.params.*.
{
"scripts": [
{
"path": "reindex.js",
"type": "action",
"label": "Reindex catalog",
"export": "run",
"params": [
{ "key": "force", "type": "checkbox", "label": "Force full reindex" },
{ "key": "batch", "type": "number", "label": "Batch size", "default": 500 }
]
}
]
}
// reindex.js
module.exports.run = (ctx) => {
const force = ctx.params.force // boolean
const batch = ctx.params.batch // number
sw.log.info(`reindex force=${force} batch=${batch}`)
// ...
}
label: (Optional) Text shown on the Run button; defaults to the script path.export: (Optional) Entrypoint function name; defaults torun.params: (Optional) Input fields prompted in a modal before the action runs. Reuses the settings field schema (text,number,select,checkbox,textarea,tags,model,editor, etc.). When omitted, the action runs immediately with no prompt. The coerced values (only declared keys, with each field'sdefaultapplied when blank) are available asctx.params.- Like scheduled "Run now", actions run in the background — output goes to the Logs tab, not streamed back — so they work for
private-source plugins too. The plugin must be active. Write actions to be idempotent and safe to re-run.
Examples: the bundled
demoplugin shipsaction.js("Run Demo Action") demonstrating text/number/select/checkbox params. Therinvenplugin'sdedup.jsis an action with adry_runcheckbox param that overrides the global setting for a single run.
Plugin Dependencies (depends)
A plugin can declare other plugins it requires with a top-level depends array of plugin ids in manifest.json:
{
"id": "loyalty-rewards",
"name": "Loyalty Rewards",
"version": "1.0.0",
"depends": ["points-engine", "email-sender"],
"scripts": [
{ "path": "hooks.js" }
]
}
Dependencies are enforced at two points:
- On install. Installing a plugin automatically installs any declared dependency that isn't already installed, resolving each from the marketplace (its active version) or from a bundled/local plugin, and recursing into transitive dependencies. Free dependencies install silently. A paid marketplace dependency is not auto-purchased — the install fails with an error asking you to install that plugin first. The marketplace install/detail modal lists a plugin's dependencies under a "Requires:" banner before you install.
- On activation. Activating a plugin first activates any inactive dependency — recursively, dependencies before dependents — then activates the plugin itself. Each dependency runs its own activation checks too (including the custom-record conflict check below). If a declared dependency isn't installed at all, activation is rejected with a clear error. Dependency cycles are detected and broken safely.
Because activation cascades, switching a plugin on also switches on its dependencies and fires each one's plugin.activate lifecycle hook (see Plugin Lifecycle Hooks). Deactivating does not cascade — dependencies are left active, since other plugins may still rely on them. In the installed-plugin sidebar each dependency is shown with a colored badge: green (active), yellow (installed but inactive — will be activated with this plugin), or red (not installed).
Custom Record Name Conflicts
Custom record types (custom_records[].id — see Custom Records) share a single per-shop namespace: the record id becomes the storage kind, so two active plugins cannot both define a record with the same id. When you activate a plugin, the platform checks every custom record id it declares against those already provided by other active plugins. If any id collides, activation is rejected with an error naming the conflicting record and the plugin that already owns it, and the plugin stays inactive — choose a more specific id (e.g. prefix it with your plugin id, like loyalty_points) to resolve the clash. The same check runs for every dependency activated in a cascade, so a conflict anywhere in the dependency tree blocks the whole activation.
Custom record fields
Each custom_records[] type declares its fields[] — the shape of that record. A field is an object; the common keys:
| Key | Purpose |
|---|---|
name | Field id — how you read/write it (ctx.data.<name>, sw.records payloads, filter keys). Required. |
type | string, textarea, richtext, number, integer, boolean, date, datetime, tags, image, model, json, or a region picker (country / us_state / ca_state). Defaults to string. |
label | Human label shown as the column header and edit-form label. Falls back to name. |
list | true shows the field as a column in the record list. |
index | true makes the field filterable in the record list (and sortable via sw.records.<type>.list). Scalar fields are indexed by default; richtext and textarea are not — set "index": true to opt one in, or "index": false to opt out. An array value (e.g. ["status", "created"]) declares a composite index — see Filter operators. |
hidden | true keeps the field out of the record list; helper/composite fields use it to stay out of the edit form too. |
unique | true rejects a save whose value duplicates another record of the same type. |
options | Fixed set of allowed string values — renders as a dropdown in the edit form and as a dropdown filter in the list. |
model + multiple | For type: "model": which entity the field references (product, customer, order, coupon, …, or custom:<type>) and whether it holds one id or many. |
Filtering the record list. A field becomes a filter in the record list when it is both shown (list: true) and indexed (index: true). The filter control matches the field type: a field with options — or a boolean — renders a dropdown; a model field renders a record picker that resolves ids to names; other types render a text box (append * for a prefix match). The list can also be sorted by when records were created or last updated. (A filter and a sort can't be combined on different fields — see Filter operators.)
Bulk edits. Fields with a constrained value picker — those with options, or a boolean that's shown in the list (list: true) — can be mass-set from the record list: select rows, choose the field, pick the value, and apply it to all of them at once.
Model-reference (model) and integer fields hold entity ids and are stored as whole numbers, so a filter like { product_id: 42 } matches whether you pass the id as a number or a numeric string.
"custom_records": [{
"id": "wish",
"name": "Wishlist",
"fields": [
{ "name": "product_id", "type": "model", "model": "product", "label": "Product", "list": true, "index": true },
{ "name": "customer_id", "type": "model", "model": "customer", "label": "Customer", "list": true, "index": true },
{ "name": "status", "type": "string", "options": ["active", "purchased"], "list": true, "index": true }
]
}]
Protecting Source Code (Paid Plugins)
By default a plugin is open-source: any shop that installs it can pull, view, and copy every file via the dev tools and export. To ship a paid or proprietary plugin whose code must not be copied, add the top-level "source": "private" key to manifest.json:
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"source": "private",
"scripts": [
{ "path": "hooks.js" }
]
}
When source is "private", the platform refuses to serve the plugin's files — pull, export, and source-view requests are rejected for everyone except the plugin's owner. Installed shops can still run the plugin (hooks, routes, widgets, cron all execute normally) and configure its settings; they simply cannot download or read its source. Omit the key (or set "") to keep the plugin open-source so its code can be freely inspected and copied.
Plugin Plans (tiered pricing)
A plugin can offer several tiers (e.g. free → pro → business) so a shop picks the plan it needs and your code gates features by tier. You declare only the ordered plan keys in the manifest:
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"plans": ["free", "pro", "biz"]
}
The keys are display/identity only — the price, display name, billing interval, and feature list are filled in at publish time (in the Publish dialog), one row per declared key, and stored on the marketplace listing. This keeps prices out of your source and lets you re-price without a code change. The first key is shown first in the comparison grid. Rules:
- Billing types per tier:
free,one_time, orsubscription(monthly). A tier priced at $0 is allowed — it's a free-but-keyed plan (e.g. a"hobby"tier). Subscriptions ride the shop's existing ShopsWired invoice and are billed/paid out exactly like single-price plugins (the price lives on the platform Stripe account; the developer is paid 70% via Connect transfer on each paid invoice). - Switching: shops upgrade/downgrade between subscription tiers in-app — Stripe prorates the change. Moving to a $0 tier removes the add-on.
one_timetiers are mutually exclusive and cannot be switched in-app (uninstall and repurchase). - Publish validation: the priced tiers must match the manifest keys exactly (no extras, none missing).
Reading the active plan — ctx.plan
Every plugin-executed script (hooks, routes, widgets, scheduled runs, payment hooks) receives the shop's active plan key as ctx.plan:
module.exports.hook_product_save = (ctx) => {
if (ctx.plan === 'biz') {
// business-tier feature
} else if (ctx.plan) {
// any selected paid/keyed plan (pro, hobby, …)
} else {
// ctx.plan === '' → no plan selected, or a legacy plugin with no `plans`
}
}
ctx.plan is the literal key the shop selected (including a $0 keyed tier). It is the empty string '' only when no plan has been selected yet, or for a plugin that declares no plans at all — so a plain if (ctx.plan) means "a plan is selected". The platform never substitutes a default key. Gate features off the key; don't trust client-side checks for anything billable.
Testing plans before publishing. A plugin you're still developing (pushed from the CLI, not yet published) has no marketplace pricing, but you can still set ctx.plan to exercise plan-gated branches: open the plugin's detail modal → Plans tab and click a plan key. This writes the selection straight to the installed record (no billing). Once the plugin is published, the Plans tab switches to the real purchase/upgrade flow and the key tracks the marketplace subscription. (Switching a real, paid subscription's tier this way is rejected — use the purchase flow.)
In-App Purchases (sw.iap)
Beyond the plan tier, a plugin can sell in-app purchases to the merchant after install — a one-time unlock or a pack of consumable credits. The shop owner is charged and you (the developer) are paid 70% via Stripe Connect, the same rails as marketplace plan purchases. This works for free and paid plugins alike.
There are two product types:
one_time— a permanent unlock. Grants an entitlement you check withsw.iap.entitled(key).consumable— a credit balance you debit withsw.iap.consume(...)(e.g. AI tokens, SMS sends).
1. Declare the catalog in manifest.json. No price here — you set the amount at runtime, so the merchant sees the exact charge on the approval screen. The keys gate sw.iap.* (a plugin can only transact products it declared) and badge the plugin as "offers in-app purchases".
"iap_products": [
{ "key": "pro-unlock", "name": "Pro Features", "type": "one_time", "max_amount": 9900 },
{ "key": "credits", "name": "Action Credits", "type": "consumable", "unit": "credits", "credits": 1000 }
]
unit and credits are optional display/default hints for consumables; the runtime amount and credits you pass to requestPurchase are authoritative.
Amount caps. A runtime amount can't exceed the platform ceiling of $10,000 (1,000,000 cents); requestPurchase throws above it. Declare an optional per-product max_amount (cents) to set a tighter ceiling for that product — useful as a self-guard so a bug can't request an unexpectedly large charge.
2. Request a purchase — the merchant must approve. sw.iap.requestPurchase never charges; it mints a short-lived signed token and returns a confirmation_url. Send the shop owner there: they review the price and approve, and only then is the charge created and the entitlement/credits granted. There is no silent-charge path.
const res = sw.iap.requestPurchase({
product: "credits", // must match a declared key
amount: 2000, // cents — shown to the owner, charged on approval
currency: "usd", // optional, defaults to usd
credits: 1000, // consumables: units granted on approval
description: "1,000 Action Credits", // shown on the approval screen + invoice
returnUrl: "/admin/...", // optional: where to send the owner after approve/decline
});
// → { purchase_id, token, confirmation_url }
How you present the approval depends on the surface:
Dashboard widget — return
res(which includestoken) to your widget's client JS and call the bundledawait sw.iap(token)client helper. The admin renders the approval modal in place (likesw.pickFile), so buying feels like a native purchase sheet without leaving the dashboard. It resolves to{ status: 'approved' | 'declined', dev }:const r = await sw.fetch('action', { method: 'POST', body: { action: 'buy' } }); const { token } = await r.json(); const result = await sw.iap(token); // host-rendered approval modal if (result.status === 'approved') location.reload();Anywhere else (route handler, email, external link) — send the owner to
res.confirmation_url(a top-level admin approval screen).
3. Check entitlements and spend credits anywhere in your plugin:
if (sw.iap.entitled("pro-unlock")) { /* unlocked */ }
const balance = sw.iap.credits("credits"); // current balance (0 if none)
// Debit credits. idempotencyKey makes a retried call safe — the same key never
// double-debits. Throws "insufficient credits" if the balance is too low.
const left = sw.iap.consume({ key: "credits", amount: 1, idempotencyKey: "send-" + msgId });
sw.iap.purchases(); // this shop's approved purchases of your plugin
4. React to an approved purchase with the iap.purchase hook (fires after the grant):
module.exports["iap.purchase"] = function (ctx) {
// ctx.data: { plugin_id, product_key, type, amount, credits, purchase_id, dev }
// Provision, notify, seed data, etc.
};
Charging requires the shop to be on a paid platform plan — the charge rides the shop's next ShopsWired invoice. The merchant always has an "In-App Purchases" panel under the plugin's Settings (purchases, entitlements, credit balances), independent of any UI your plugin ships.
DEV mode (no charge). When the shop owns the plugin (you're testing your own plugin on your own shop) or the plugin isn't published yet, approving records the purchase and grants the entitlement/credits but never charges — the approval screen and history show a DEV badge. This lets you exercise the full flow end to end without paying yourself.
See
cli/plugins/demofor a working example: theiap_productsmanifest block, theiap.purchasehook inhooks.js, and the In-App Purchases dashboard widget (widgets/iap.js) that shows status and requests purchases.
Settings
The settings array defines configuration options for your plugin. Supported types include text, textarea, number, checkbox, select, image, richtext, editor (for raw code with syntax highlighting), color, tags, link (a button that opens a URL — see below), and the region pickers country, us_state, ca_state (see below).
For the editor type, you can optionally specify the language using the options object:
{
"key": "custom_css",
"type": "editor",
"label": "Custom CSS",
"options": { "language": "css" }
}
Link fields (link)
A link field renders as a button that opens its url in a new tab — it holds
no value, so use it to surface a URL the merchant needs (a generated feed, a
hosted document, an external dashboard) right inside the settings panel without
shipping a widget. Two placeholders in url are resolved server-side when the
settings are loaded:
{shop_id}— the numeric shop id (handy as a?shop=query param so the storefront router can resolve context).{shop_url}— the shop's canonical storefront origin (custom domain if set, otherwise<subdomain>.shopswired.com), with no trailing slash — so you can link to one of your own storefront routes without knowing the merchant's domain.
{
"tab": "General",
"key": "feed_link",
"type": "link",
"label": "Open Google Merchant feed",
"url": "{shop_url}/feeds/google.xml",
"help": "Right-click → Copy Link Address to paste into Google Merchant Center."
}
Placeholders are only substituted in url, not in label/help.
Region pickers (country / us_state / ca_state)
Three field types render a dropdown backed by the platform's built-in region lists, so you never ship your own country/state data:
country— full ISO country list (same list used by checkout addresses, tax rules, and shipping zones).us_state— US states + DC.ca_state— Canadian provinces/territories.
{ "key": "ship_from", "type": "country", "label": "Ship-from country", "default": "US" }
The stored value is the canonical ISO code (e.g. "US", "CA", "TX",
"ON") — matching order.shipping.country / .state and the values returned
by core region helpers. These types work anywhere the settings field schema is
used: plugin/theme settings, action params, widget config_defs, and
custom record fields.
Conditional fields (condition)
Any settings field can carry a condition string so it only shows when another
field in the same settings form has a given value. The grammar is a single
equality test, key == value:
[
{ "key": "ship_from", "type": "country", "label": "Ship-from country", "default": "US" },
{ "key": "state_us", "type": "us_state", "label": "State", "condition": "ship_from == 'US'" },
{ "key": "state_ca", "type": "ca_state", "label": "Province", "condition": "ship_from == 'CA'" },
{ "key": "api_url", "type": "text", "label": "API URL", "condition": "use_custom == true" }
]
- The value may be a quoted string (
'US'/"US"), a bare token (US, kept as a string), a number (2), ortrue/false. Comparison is strict equality against the referenced field's current value. - Only
==is supported — there is no!=,&&,||, orpresent. For mutually exclusive variants (e.g. a US state field vs a CA province field), add one conditional field per case, as above. - Hiding is display-only — a hidden field still keeps whatever value it
holds and is still sent to your script. Don't rely on
conditionto clear a value; read the field your logic actually needs (e.g. fall back acrossstate_us/state_ca).
Settings layout (tab and group)
Two optional, purely-visual string properties organize a long settings form.
Both keep each field's value flat under its own key — they only affect
layout, so adding/removing them never changes what your script reads.
tab— puts the field on a named tab. Fields with notabfall on aGeneraltab. Tabs render as a row of buttons across the top of the form.group— renders a small sub-heading above a run of fields that share the samegrouplabel, within a tab. Group fields should be listed consecutively in the schema (the heading is emitted when the label changes).
[
{ "tab": "Distance Rules", "group": "Origin", "key": "origin_postal", "type": "text", "label": "Origin ZIP" },
{ "tab": "Distance Rules", "group": "Origin", "key": "origin_country", "type": "country", "label": "Origin country", "default": "US" },
{ "tab": "Distance Rules", "group": "Origin", "key": "origin_state", "type": "us_state", "label": "Origin state", "condition": "origin_country == 'US'" },
{ "tab": "Distance Rules", "key": "max_transit_days", "type": "number", "label": "Max transit days", "default": 2 }
]
Don't confuse group (this flat sub-heading) with the type: "group" field,
which is a collapsible group whose children nest their values under the
group key. Use group for layout; use type: "group" only when you actually
want a nested settings object.
Permissions
A plugin can declare its own permissions — capability flags an admin grants to individual shop staff under Settings → Users → Permissions. Each user can hold any number of them. They are separate from the built-in shop roles (owner/admin/staff); use them when one plugin needs finer-grained, per-user access than the shop role provides.
"permissions": [
{ "key": "view_orders", "label": "View Orders", "description": "See the recent-orders count." },
{ "key": "view_financials", "label": "View Financials", "description": "See revenue totals." }
]
key— the identifier you check at runtime (stable;[a-zA-Z0-9_-]).label/description— shown in the Users tab checklist.labeldefaults tokey.
Grants can be assigned two ways, and a user's effective set is the union of both:
- Per user — checked off for an individual under Settings → Users → Permissions (one-off overrides).
- Per role — baked into a role under Settings → Users → Roles, so everyone holding that role inherits them without re-adding per user. (Built-in roles carry no plugin grants; custom roles can.)
Either way they're surfaced to your widget scripts (only — they're a staff concept, so storefront route/hook contexts don't receive them):
ctx.permissions— array of the keys this plugin declared that the current user holds (from their role and their per-user grants, merged + de-duped), e.g.["view_orders"]. De-namespaced, so just check your own keys; you never see other plugins' grants.ctx.role— the user's role slug. This is a built-in role (owner/admin/staff) or a shop-defined custom role slug, so treat it as an opaque string for display/branching, not an exhaustive enum. Gate capabilities onctx.permissions, not onctx.role.
// widgets/snapshot.js
module.exports.fetch = function (ctx) {
const can = (p) => (ctx.permissions || []).indexOf(p) !== -1;
if (!can('view_orders')) {
// omit the orders panel entirely for users without the grant
}
// ctx.role is also available, e.g. for owner/admin-only affordances
};
Enforce permissions on the server side of fetch(ctx) (skip the query, omit the data) — not just in the rendered HTML — since the grant gates what the script computes. The bundled Demo Plugin (cli/plugins/demo) ships a working example: its Shop Snapshot widget gates the recent-orders panel on view_orders and the revenue line on view_financials, and prints the viewer's role + granted permissions.
Gating custom records with permissions
A custom-record type can be scoped to one of your declared permissions via a
permissions map on its custom_records[] entry — permission key → access
level ("read" | "write" | "full", where full includes delete and bulk):
"custom_records": [
{
"id": "review",
"name": "Reviews",
"permissions": { "reviews_view": "read", "reviews_manage": "full" },
"fields": [ /* … */ ]
}
]
A user gets the highest level among the permission keys they hold (resolved
from their role's plugin grants ∪ their per-user grants); the shop owner
always has full access. Because access rides the permission — not a role name —
a merchant grants any custom role (a Viewer, a Reviews Moderator, …) access
just by attaching reviews_view/reviews_manage to it in the role builder; the
plugin never has to know the shop's role slugs.
If a record type declares no permissions, access falls back to the shop's
core records capability (the records resource in the role builder), so any
role with records: read/write/full can use it. (This replaces the older
role-name-keyed roles map, which couldn't address custom roles.)
Acting on behalf of customers (impersonation)
Customer impersonation (a staff member operating as a shopper — to build a
cart or place an order on their behalf) is plugin-driven. There is no
built-in "Sales Rep" role and no impersonate capability baked into the roles
system; a plugin owns the flow end-to-end. Core provides one building block:
sw.customers.startImpersonation, which mints a stateless customer session.
Manifest opt-in
The capability is gated by a manifest flag — a plugin must declare it, so a merchant can see (in the plugin's status panel) that it can impersonate before activating:
{
"id": "sales-desk",
"impersonation": true,
"permissions": [
{ "key": "impersonate", "label": "Impersonate customers" }
]
}
Without "impersonation": true, sw.customers.startImpersonation throws. The
flag is the platform gate; the plugin still gates its own "Login As" UI on a
permission it declares (e.g. impersonate) and checks via ctx.permissions.
The flow
- The plugin renders a widget (its own "Login As" button/email) and checks
ctx.permissionsso only authorized staff see it. - On click, the widget calls
sw.customers.startImpersonation(customerId), which returns{ token, url }— a 5-minute, stateless token carrying the customer id, shop id, and the acting staff user's id. The call is audited (IMPERSONATE_START) and rate-limited (30/min per plugin). It must run in an admin/widget context (an acting user must be on record) — it is unavailable from storefront routes/hooks. - The plugin hands the user to
url(the storefront/impersonate/start?token=…route). That route validates the token, swaps it for a 1-hour customer session cookie that carries the acting rep's id, and redirects to/account. - The staff member is now logged in as the customer and can build a cart or
check out. Orders placed in that session are stamped with
order.created_by_user_id= the rep (see Orders) for attribution/commission — the same field admin-built manual orders use.
// widgets/login-as.js (gated on ctx.permissions)
module.exports.fetch = function (ctx) {
if ((ctx.permissions || []).indexOf('impersonate') === -1) return { html: 'Not authorized' };
const customerId = Number(ctx.request.query.customer_id);
const { url } = sw.customers.startImpersonation(customerId);
return { html: `<a class="sw-btn" href="${url}">Log in as customer</a>` };
};
What core also provides:
- Granular roles — a shop owner mints custom roles (e.g. Sales Rep,
Viewer) with read/write/full per resource. Built-in roles are only
owner/admin/staff. Attach yourimpersonatepermission to a role so it propagates toctx.permissionsfor everyone in it (see Permissions). - The admin order builder — alternatively, any role with
orders: writecan compose an order on a customer's behalf server-side without a storefront session at all; it's likewise stamped withorder.created_by_user_id. This is plain staff selling, distinct from logging in as the customer.
Event Hooks
Scripts export functions to hook into system events. The export key defines the hook name.
Need the exact shape of
ctxorctx.data? Entities.md is the field-level reference: the fullctxobject (hooks, widgets, routes, tasks), a per-hook table of whatctx.dataholds, and every built-in entity's fields.
Data Hooks
Hooks allow you to intercept database saves and modifications.
product.before_save/product.after_saveorder.before_save/order.after_savecustomer.before_savecoupon.before_save/coupon.after_saverecord.<type>.before_save/record.<type>.after_saveandrecord.<type>.before_delete/record.<type>.after_delete— for custom record types (see below).
Example:
module.exports = {
"product.before_save": function(ctx) {
if (!ctx.data.sku) {
ctx.data.sku = "AUTO-" + Date.now();
}
// To abort the save:
// throw { error: "Missing SKU" };
}
};
Custom-record CRUD hooks (record.<type>.*)
Every custom record type fires four CRUD hooks; <type> is the custom_records[].id (e.g. a review type → record.review.before_save). They fire for every save/delete of that kind in the shop — admin edits, sw.records.<type>.save(...), and writes from other plugins alike — so the owning plugin sees all mutations. Hooks can live in any .js file the plugin ships; discovery picks them up regardless of the script's manifest type.
ctx.data is the record, in the same flattened shape sw.records.<type>.save/get return — the declared fields sit at the top level alongside the envelope keys (id, kind, created, updated), just like the built-in product/order/customer hooks:
ctx.data = { id, kind, created, updated, /* your schema fields */ }
ctx.old_data // the record's previous state (same shape), or undefined on create
So you read and write fields directly at ctx.data.<field>:
module.exports = {
// before_save: validate or mutate before persistence. Field writes are saved.
"record.review.before_save": function (ctx) {
const rec = ctx.data;
if (!rec.status) rec.status = "Pending"; // default a field
if (!rec.rating) throw { error: "Rating required" }; // throw to abort (403)
},
// after_save: react to the committed write (side effects). Return value ignored.
"record.review.after_save": function (ctx) {
const now = ctx.data; // new values
const prev = ctx.old_data || null; // null on create
// e.g. roll up an average, send a notification, enqueue a task…
},
"record.review.before_delete": function (ctx) { // throw to block the delete
if (ctx.data.locked) throw { error: "Locked review" };
},
"record.review.after_delete": function (ctx) {
// ctx.data is the deleted record; clean up derived state.
}
};
Notes:
before_saveruns before the schema pass that builds composite index fields and allocates the id, so on a createctx.data.idis still0(populated byafter_save). Any composite index field is (re)computed from your final field values afterbefore_save, so defaulting a member field here (as the Blog plugin does forpublished_at) flows into the composite correctly.- Mutations persist by field diff — edit
ctx.data.<field>in place; changed top-level fields are merged back into the record (the envelope keysid/kind/created/updatedare read-only and ignored). Returning a value is not required. - Throwing aborts
before_save/before_delete(surfaced as a403);after_*throws are logged but don't roll back the write. - Keep them light — like all data hooks they run inline on the write path (~5s budget); offload heavy work to
sw.task.bg.
Checkout & Cart Hooks
coupon.validate: Custom validation logic for coupons.shipping.calculate: Compute or filter available shipping rates.ctx.data.addresscarries the destinationcountry,state,zip, andcity— the initial checkout render rates the customer's saved address, and the bundled default theme's address recalc POSTscountry/state/zip/cityto/calculate-shipping(it fires when any of those change). So a ZIP-dependent carrier (UPS/FedEx live rates or transit time) can rate here. Two caveats: (1) the ZIP may be blank early in the flow (before the customer fills it) or on a custom theme that doesn't send it — handle a missing ZIP gracefully (offer a flat/placeholder option so checkout stays possible); and (2) replacectx.data.optionswith a non-empty array to override the built-in options — an empty array is ignored (it won't clear built-in methods). For an authoritative gate that's guaranteed the full address regardless of theme, also enforce incheckout.before_create(itsorder.shippingalways has the complete address).tax.calculate: Provide external tax calculation.cart.calculate_prices: Override the per-line unit price (see below).checkout.before_create/checkout.after_payment: Validate / react to an order being placed;before_createcan also rewrite line-item prices for a final snapshot.payment.calculate_adjustment: Push +/- adjustment lines onto an order based on the chosen payment method (e.g. credit-card surcharge, wire discount).payment.before_intent: Run a last validation before any payment gateway call. Throw to block; optionally includeredirect_urlto bounce the customer.
cart.calculate_prices — dynamic line-item pricing
Fires every time the storefront computes cart prices: cart-page render, checkout-page render, and at the top of checkout submission. Use it for prices that depend on something the catalog can't capture: a live spot quote, a customer-specific tier, a time-of-day discount.
module.exports = {
"cart.calculate_prices": function (ctx) {
// ctx.data.items is a [{product_id, shop_id, name, set, qty, price}, ...]
const items = ctx.data.items;
for (let i = 0; i < items.length; i++) {
const p = sw.products.get(items[i].product_id);
// Identify live-priced products by a (hidden) product attribute
// rather than a stored price — see the bullion recipe below.
if (!p || !p.attrs || !p.attrs.some(a => a.name === "Metal")) continue;
items[i].price = computeLivePrice(p, items[i].qty); // cents
}
}
};
Mutating items[i].price overrides the engine-computed price (tier / variant / wired markup) for that line. Touching qty, name, etc. has no effect — only price is read back. The engine recomputes the cart subtotal after the hook returns and writes the new prices to the cookie/customer cart so the next render is consistent.
For one-off products whose price is a fixed override stored on the product itself, prefer the native PriceTiers field — cart.calculate_prices is for prices the database can't pre-compute.
checkout.before_create — final price snapshot
checkout.before_create was historically a validation-only hook (throw to block). It now also reads back per-item price mutations under ctx.data.order.items[i].price. This is where dynamic-pricing plugins should bake in the final snapshot — cart.calculate_prices keeps the display fresh while shopping, but checkout.before_create is the last moment before the order is persisted.
module.exports = {
"checkout.before_create": function (ctx) {
const order = ctx.data.order;
for (let i = 0; i < order.items.length; i++) {
order.items[i].price = computeLivePrice(order.items[i]);
}
// Want a price-lock window? Stamp it on order.meta — there's no
// engine-side lock; payment.before_intent (below) is the seam for
// enforcing it on subsequent payment attempts.
order.meta = order.meta || {};
order.meta.price_lock_expires = Date.now() + 10 * 60 * 1000;
}
};
Subtotal and Total are recomputed from the new prices. Discount, Shipping, and Tax are preserved — modify those via coupon.validate / shipping.calculate / tax.calculate instead.
Blocking by visitor network data. checkout.before_create also receives
ctx.request, so a fraud/visitor-blocker can reject a checkout on IP, country,
or bot score — not just ctx.data.order email/phone. Throw to block; include a
redirect_url to bounce the buyer:
"checkout.before_create": function (ctx) {
const h = ctx.request.headers;
if (isBlockedIp(h["X-Real-Ip"]) || h["X-Geo-Country"] === "XX") {
throw { error: "Checkout unavailable", redirect_url: "/cart" };
}
}
See Visitor network data for the full header list.
Customer price levels — native B2B / tiered pricing (no hook)
For "logged-in customers see a different price" (wholesale, dealer, contract pricing), use the native price-level system instead of a hook. It applies consistently across every storefront product-load path — product listings, the products Liquid filter, layout_render blocks, the PDP, cart, and checkout — including the inline-filter paths that no plugin hook can reach.
How it works:
- The shop defines named levels in
shop.price_levels(admin/config), each with akey, optionallabel, and a default fallback formula:mode: "named_only"(default) — only products that have an explicitprices[key]entry get a special price; everything else stays retail.mode: "percent"—valueis basis points off the retail price (1500= 15% off).mode: "amount"—valueis cents off the retail price.show_compare: true— setscompare_priceto the retail price so themes render a strikethrough.rules— an ordered waterfall of{match_tag, match_attr_name, match_attr_value, mode, value}. The first rule whose tag/attribute matches the product wins (same matching semantics as wired markup rules — empty conditions are ignored/ANDed). Use it for category- or attribute-specific discounts (e.g. trade gets 30% offclearance, 10% offMaterial=Gold). Evaluated before the level's defaultmode/value.
- Each product's
pricesmap ({"wholesale": 1999, "dealer": 1799}) holds explicit per-level prices. An explicit entry always wins over rules and the default formula. - A customer is assigned a level by setting
price_levelon their record — the "flip":
// Approve a B2B customer for wholesale pricing (persists; takes effect next page load).
sw.customers.save({ id: customerId, price_level: "wholesale" });
Once flipped, the platform computes the price natively at every load site — there is no per-product hook and no runtime cost beyond a map lookup + a short rule scan. Resolution order per product: explicit prices[level] → first matching waterfall rule → level default formula → retail. Variants follow the same rule (an explicit per-variant prices[level] wins; a variant with no own price inherits the adjusted product price; rules match against the product tags plus the variant's own attributes).
This is persistent, catalog-level pricing. For pricing the catalog can't precompute — live spot quotes, cart-total thresholds, time-of-day discounts — use cart.calculate_prices (above), which is the bullion-pricing strategy. The two compose: wired markup → customer level → cart.calculate_prices.
payment.calculate_adjustment — payment-method conditional adjustments
Fires during checkout submission if the form posts payment_method=<id>. The hook returns +/- adjustment lines that are appended to order.adjustments and folded into Totals.Total. Multiple plugins compose: each pushes its own entries onto ctx.data.adjustments and the engine keeps them all.
module.exports = {
"payment.calculate_adjustment": function (ctx) {
const method = ctx.data.payment_method;
const subtotal = ctx.data.order.totals.subtotal;
if (method === "stripe") {
ctx.data.adjustments = (ctx.data.adjustments || []).concat([{
label: "Credit card surcharge (3%)",
amount: Math.round(subtotal * 0.03)
}]);
} else if (method === "wire") {
ctx.data.adjustments = (ctx.data.adjustments || []).concat([{
label: "Wire discount (2%)",
amount: -Math.round(subtotal * 0.02)
}]);
}
}
};
Each entry is {label: string, amount: integer cents}. Negative amounts are discounts. The single-object shape ctx.data.adjustments = { label, amount } is accepted as a convenience for the common one-line case.
To wire it up on the storefront, add a <select name="payment_method"> to the checkout form so the chosen method is posted with the order. The plugin then surfaces its adjustments on the receipt template via {% for a in order.adjustments %}{{ a.label }} {{ a.amount | money }}{% endfor %}.
payment.before_intent — pre-payment validation
Fires immediately before the payment gateway's payment.create_intent is called, on both the initial AJAX checkout path and the retry endpoint. Throw to abort the attempt:
module.exports = {
"payment.before_intent": function (ctx) {
const order = ctx.data.order;
const exp = order.meta && order.meta.price_lock_expires;
if (exp && Date.now() > exp) {
throw {
error: "Prices have changed since you started checkout. Please review your cart.",
redirect_url: "/cart"
};
}
}
};
Any field on the thrown object (beyond error) is available to the storefront via the HTTP 410 JSON response — redirect_url is the conventional one for "kick the customer somewhere". The storefront's checkout script handles 410 by reading redirect_url and navigating.
Payment Gateway Hooks
Payment gateway scripts (type: "payment" in the manifest) can export:
payment.create_intent: Process a payment. Set the result onctx.data.payment:ctx.data.payment.id— Payment provider's transaction IDctx.data.payment.status—"paid"or"pending"ctx.data.payment.method— (Optional) Tender used —"card","ach","cash_app", etc. Stored separately from the provider (which is the gateway). Pre-seeded with any method already on the order, so you can read or override it.ctx.data.payment.url— (Optional) Redirect URL for hosted checkoutctx.data.payment.client_secret— (Optional) Client-side secret for SDK confirmation
Recurring (subscriptions). When the order contains a subscription line the platform sets directives on the input
ctx.data.paymentthat a recurring gateway acts on (see "Recurring subscriptions" below). All are optional — a one-time charge sets none of them:ctx.data.payment.save_method— vault the payment method during this charge and return a reusable token onctx.data.payment.vault_token(an opaque string the platform stores encrypted and never parses, e.g. a JSON blob of customer + payment-method ids).ctx.data.payment.trial— vault the method but don't charge now (free trial); setstatusto"paid"for the $0 order.ctx.data.payment.off_session+ctx.data.payment.vault_token— a renewal: charge the vaulted token with no shopper present. Setstatusto"paid","failed"(e.g. the card now needs authentication), or"pending".
payment.webhook: Handle provider webhook callbacks. The hook always runs in a known shop's context (the platform resolves it from the URL or, for connected accounts, frompayment.webhook_account— see below). Set one of:- Payment update: set
ctx.data.order_id(e.g. from the provider'sreference_id/metadata you stamped atcreate_intent) plusctx.data.payment_idandctx.data.payment_status. Use"paid"to confirm, or"failed"for an async failure (e.g. an ACH return) — the platform then marks the orderpayment_failed, returns the reserved stock, and reverses any wired-supplier ledger. A redundant"paid"on an already-paid order is a no-op, and"failed"never overrides a"paid"order. For a subscription signup whose first charge settles asynchronously, also setctx.data.vault_tokenon the"paid"event so the platform can create the contract with a reusable token (it's normally returned synchronously fromcreate_intentinstead). - Refund update (for refunds that settle asynchronously — ACH, some
wallets): set
ctx.data.refund_id(the provider's refund id, the same value you returned frompayment.refund) andctx.data.refund_status("succeeded"or"failed"). You don't needorder_id— the platform stores the provider refund id on the order and resolves the order fromrefund_id(passorder_idtoo if you happen to have it). It flips the matching refund frompendingto its final state, and only then marks the order refunded, sends the refund email, restocks, and reverses any wired-supplier ledger. If arefund_idis present it takes precedence over a payment update.
Convention — nested vs flat
ctx.data. A hook that fills a domain object the platform hands you uses that nested object:create_intentsetsctx.data.payment.{id,status,url,client_secret}. A hook that reports discrete facts about an external event sets flat, prefixed fields:payment.webhooksetspayment_id/payment_status(a payment event) orrefund_id/refund_status(a refund event), where the prefix discriminates which kind it is. Pick the shape by interaction type, not by the noun in the name.- Payment update: set
payment.refund: Refund money against a payment. The platform handles full vs. partial amounts, restocking fees, order status and the audit trail — your hook only needs to call the provider with the supplied amount.ctx.datacarries:amount— positive cents to refund (already validated against the remaining balance; may be less than the order total for a partial refund)currency— order currencyreason— admin notepayment_id— the original charge/payment ididempotency_key— unique per attempt; pass it to the provider so a retry never refunds twice
Report the outcome by setting:
ctx.data.refund_id— the provider's refund idctx.data.status—"succeeded"(money returned) or"pending"(provider settles later and will confirm viapayment.webhook)
Throw (or
ctx.preventDefault(msg)) to reject the refund; the platform records the attempt asfailedwith your message. Admins can also issue a manual refund (cash/offline, or a gateway with no refund API), which is recorded without calling this hook at all.payment.webhook_account: (Connected-account gateways only.) A bridgeless parser — it runs with nosw.*globals — that extracts the provider account id from a no-shop webhook body so the platform can resolve the owning shop before runningpayment.webhook. Readctx.data.bodyand setctx.data.account_id(e.g.ctx.data.account_id = JSON.parse(ctx.data.body).merchant_id). Pair it withsw.payments.linkAccount(accountId)at OAuth-connect time. Own-keys gateways (shop in the URL) don't need this hook. It runs before the shop is known, so it cannot read per-shop settings — derive the id purely fromctx.data.body/headers. The account id may be hierarchical (colon- separated, most-specific first, e.g."merchant_id:location_id"): the platform tries the full id, then strips trailing:segments and retries, so one provider account can fan out to several shops by sub-scope while a bare-id link (or an event with no sub-scope) still resolves via the prefix. Emit the most specific id the body carries, and link the matching key.
Example:
// manifest.json: { "path": "payment.js", "type": "payment", "gateway_id": "my-pay" }
module.exports = {
"payment.create_intent": function (ctx) {
const order = ctx.data.order;
const result = processPayment(order);
ctx.data.payment.id = result.transaction_id;
ctx.data.payment.status = 'paid';
},
"payment.refund": function (ctx) {
const d = ctx.data;
const out = callProviderRefund({
payment_id: d.payment_id,
amount: d.amount, // partial when < order total
idempotency_key: d.idempotency_key // never double-refund on retry
});
d.refund_id = out.id;
d.status = out.settled ? 'succeeded' : 'pending';
}
};
Refunds accumulate on the order (order.refunds); once the refunded amount
reaches the total the order moves to refunded, otherwise partially_refunded.
The admin API is POST /admin/api/v1/orders/{id}/refund with an optional body:
{ "amount": 500, "reason": "...", "manual": false, "restock": false, "items": [{ "product_id": 1, "shop_id": 0, "quantity": 2 }] }
— an empty body refunds the full remaining balance. amount is always what the
gateway returns (so it can differ from the items' value to keep a restocking
fee); items is an optional breakdown that drives restock and, for wired
orders, attributes the supplier-ledger reversal to the right shop (shop_id is
auto-filled from the order when omitted).
Configuring payment webhooks
There is one public webhook endpoint, keyed by plugin id and gateway id, with an optional shop suffix:
POST /api/payment-webhook/{plugin}/gateway/{gateway} # no shop — platform resolves it
POST /api/payment-webhook/{plugin}/gateway/{gateway}/shop/{shop} # runs in that shop's context
A single plugin may declare several payment gateways (one type: "payment"
script each, with distinct gateway_ids), so the gateway segment always
selects which script handles the event. It is the script's gateway_id — the
same value stored as the shop's payment_provider. The Settings → Payments tab
shows the correct URL for the active gateway.
It requires no admin auth (external providers can't log in) — your
payment.webhook hook is responsible for verifying authenticity (signature
check) before trusting anything. In all forms payment.webhook runs in a
known shop's context — it is never executed un-namespaced. They differ only in
how that shop is determined:
Own-keys gateways (each merchant uses their own API keys, e.g. Stripe): give every merchant the per-shop URL
…/payment-webhook/stripe-payment/gateway/stripe/shop/{their_id}. The shop is in the URL, so its per-shop signing secret resolves and you only reportorder_id.Connected-account gateways (one app connects many sellers, e.g. Square): register the single URL
…/payment-webhook/square-payment/gateway/squareonce at the provider's app. There's no shop in the URL, so the platform resolves it in two steps beforepayment.webhookever runs:- At connect time, your OAuth callback links the provider account id to
the connecting shop with
sw.payments.linkAccount(merchantId). The platform owns the storage format (a cross-shop account→shop index), so you never name a storage key yourself. - On each webhook, the platform runs your
payment.webhook_accounthook — a bridgeless parser with nosw.*globals, so it can't touch any namespace while the shop is still unknown — to pull the account id out of the body (ctx.data.account_id = JSON.parse(ctx.data.body).merchant_id). It then looks up the linked shop and runspayment.webhookin that shop's context.
To serve one provider account from multiple shops, link a colon-separated id (
merchant_id:location_id) so each shop owns a distinct sub-scope; the platform strips trailing:segments on lookup, so bare-id links and sub-scope-less events still resolve — see "Linking connected accounts (sw.payments)" for the full pattern, including freeing the mapping inplugin.uninstall.Because the hook ends up namespaced to the seller's shop, per-shop
sw.storageand the per-shopaccess_tokenresolve normally; app-level signing secrets live as platform (NS 0) secrets and resolve via the marketplace-secret fallback.- At connect time, your OAuth callback links the provider account id to
the connecting shop with
Secrets referenced as {secret.KEY} are expanded at the HTTP/crypto boundary —
they're substituted inside fetch headers/body and inside
crypto.createHmac(...), and never materialise as plaintext in the script.
So a webhook signing secret does not need "readable": true; pass the
placeholder straight to createHmac:
cryptois a top-level global, notsw.crypto. LikefetchandFormData, it lives at the script root — usecrypto.createHmac(...)/crypto.timingSafeEqual(...).sw.cryptoisundefinedand throws "Cannot read property 'createHmac' of undefined".
createHmac('sha256', secret).update(data).digest(encoding) supports
'hex' and 'base64' encodings (Stripe uses hex; Square uses base64).
Base64: use the btoa / atob globals (same as the browser and Node), not a
crypto method. btoa(str) encodes a binary string to base64; atob(str)
decodes one. btoa operates on Latin-1 (one byte per character) and throws on
code points above 0xFF, so encode arbitrary Unicode through UTF-8 first, exactly
as on the web:
btoa('hello'); // 'aGVsbG8='
atob('aGVsbG8='); // 'hello'
btoa(unescape(encodeURIComponent('café'))); // UTF-8 then base64
// Verify a Stripe-style "t=...,v1=..." signature.
"payment.webhook": function (ctx) {
const headers = ctx.data.headers || {};
let t = '', v1 = '';
(headers['Stripe-Signature'] || '').split(',').forEach(function (p) {
const kv = p.split('='); if (kv[0] === 't') t = kv[1]; else if (kv[0] === 'v1') v1 = kv[1];
});
const expected = crypto
.createHmac('sha256', '{secret.STRIPE_TEST_WEBHOOK_SECRET}')
.update(t + '.' + (ctx.data.body || ''))
.digest('hex');
if (!v1 || !crypto.timingSafeEqual(expected, v1)) throw new Error('invalid webhook signature');
// ... parse ctx.data.body and set shop_id/order_id + payment_* or refund_* ...
}
Compare signatures with
crypto.timingSafeEqual(a, b), not===/!==. It returnstrueonly when the two strings are byte-for-byte equal, comparing in constant time so the verification doesn't leak the expected signature through timing. Always use it for webhook-signature / HMAC checks (a plain!==short-circuits on the first differing byte). Returnsfalsewhen the lengths differ.
Randomness. crypto.randomUUID() returns a random v4 UUID (a nonce, an
idempotency key, a record id). crypto.randomBytes(n) returns n cryptographically
secure bytes wrapped Node-style — call .toString(encoding) to render them, where
encoding is 'hex' (default), 'base64', 'base64url', or 'utf8'/'latin1'
(raw). Use it to mint a fresh signing secret to store via sw.secrets.set, then
pass to sw.jwt:
const id = crypto.randomUUID(); // '9f1c…-4e7a-…'
const secret = crypto.randomBytes(32).toString('base64url');
sw.secrets.set('SIGNING_KEY', secret); // persist once, reuse for sw.jwt.sign/verify
There are two credential models, and webhook setup differs between them:
Own-keys (e.g. the bundled Stripe plugin). Each merchant enters their own provider API keys as plugin secrets, and each merchant adds a webhook endpoint in their own provider dashboard pointing at the shop-scoped URL
…/payment-webhook/stripe-payment/gateway/stripe/shop/{their_shop_id}, then pastes that endpoint's signing secret into the plugin secret (STRIPE_TEST_WEBHOOK_SECRET/STRIPE_LIVE_WEBHOOK_SECRET). The hook verifies the per-endpoint signature and readsorder_idfrom event metadata you stamped at create-intent/refund time (e.g.metadata[order_id]); the shop comes from the URL.Platform-connected accounts (e.g. the bundled Square plugin). The plugin holds the platform's application credentials (
app_id/app_secret) and merchants connect via OAuth ("Connect with Square" →oauth/connect→oauth/callback, storing a per-shopaccess_tokenwithsw.secrets.setand callingsw.payments.linkAccount(merchant_id)). The provider sends events for all connected merchants to a single webhook URL configured once on the platform's provider app (…/payment-webhook/square-payment/gateway/square) — merchants don't set up webhooks themselves. The bridgelesspayment.webhook_accountparser returns the event'smerchant_id(optionallymerchant_id:location_id, so one Square merchant can back several shops — one per location); the platform resolves the linked shop and runspayment.webhookin it, where you readorder_idfrom per-shopsw.storage(indexed at refund time).
In both cases the hook reports results the same way (payment_id +
payment_status, or refund_id + refund_status), as described under
payment.webhook above.
Recurring subscriptions
The platform — not the gateway — owns subscriptions: the contract, the billing
clock, dunning/retries, trials, proration and lifecycle all live in the platform.
A gateway opts in to recurring billing by exposing just two primitives through
the existing payment.create_intent hook — vault a payment method and
charge it off-session — so there are no new gateway hooks to implement.
Declare the capability on the payment script in your manifest:
{ "path": "payment.js", "type": "payment", "gateway_id": "stripe", "recurring": true }
"recurring": true tells the platform this gateway can vault + charge
off-session; without it, a shop can't sell subscription products through the
gateway (the Settings → Payments UI shows whether the active gateway supports
subscriptions).
Merchants turn a product into a subscription in the product editor
(product.subscription): a set of plans (key, label, interval of
weekly|monthly|quarterly|yearly, an optional fixed price or discount %, and
an optional first_cycle_discount %), plus optional trial_days and max_cycles
(0 = unlimited), and a required flag for subscription-only products. A plan may
also carry a variant (an option set, e.g. {"Size":"S"}) to scope it to a
single variant; when a variant has any scoped plans they override the unscoped
("all variants") plans for that variant only, and the per-cycle price derives from
that variant's price. The storefront PDP renders a plan selector that posts a
plan field with the add-to-cart form; the chosen plan rides through checkout on
the order line, and Plan resolution is variant-aware at checkout and on renewal.
The lifecycle, end to end:
- Signup. At checkout the platform calls
create_intentwithctx.data.payment.save_method = true(ortrial = trueto defer the first charge). Your hook charges (unless trialing) and vaults the method, returning an opaque reusable token onctx.data.payment.vault_token. The platform stores it encrypted on a new subscription contract and schedules the first renewal. (If the first charge settles asynchronously, return the token on thepayment.webhook"paid"event instead — see above.) - Renewal. A platform cron finds due subscriptions and, for each, builds a
new order (the invoice) and calls
create_intentagain withctx.data.payment.off_session = trueand the storedvault_token. Your hook charges the vaulted method with no shopper present and returnsstatus. A"paid"renewal flows through the normal paid path (fulfillment, digital delivery,checkout.after_payment);"pending"is reconciled later by yourpayment.webhook. Tax is recomputed live on every renewal against the shop's current rules and the subscription's shipping address — so thetax.calculatehook fires each cycle (and at signup) just as it does at checkout. Shipping is snapshotted at signup and re-charged each cycle; it (and tax) are re-priced — firingshipping.calculate+tax.calculate— when the customer changes the subscription's shipping address.checkout.before_createdoes not fire on renewals (the recurring price is deterministic). - Dunning. A
"failed"(or errored) renewal charge increments the failure count and reschedules a retry; after the retry cap the platform cancels the subscription. You don't implement any of this — just return the charge result. - Lifecycle. Customers cancel/pause/resume from their account page; admins view and cancel from the Subscriptions screen. Cancelling/pausing simply drops the contract from the renewal sweep — no gateway call is made (the vaulted method is just no longer charged).
The token is opaque to the platform — it is stored encrypted and only ever
handed back to your create_intent on renewal, so put whatever you need in it
(e.g. JSON.stringify({ customer, payment_method }) for Stripe). A gateway that
ignores these flags simply doesn't support subscriptions; everything else
(one-time charges, refunds, webhooks) is unchanged.
Search Provider Hooks
Search provider scripts (type: "search" in the manifest) replace the built-in full-text search engine. When active, all indexing and querying flows through your plugin.
Manifest entry:
{
"path": "search.js",
"type": "search",
"search_id": "my-search",
"search_name": "My Search Engine",
"search_color": "#5468ff"
}
Search scripts export four hooks:
search.query: Perform a product search. Receivesctx.datawithquery,cursor,limit,filters,facets,sort,price_min,price_max,own_only. Must set:ctx.data.products— Array of{id, shop_id}referencesctx.data.cursor— Next page cursor (empty string if last page)ctx.data.total_count— Total number of matching productsctx.data.facets— (Optional) Map of facet name →[{value, count}]
search.index: Index products (batch). Receivesctx.data.productsas an array of full product objects. Called during normal saves and re-indexing.search.remove: Remove products from the index. Receivesctx.data.product_idsas an array of{id, shop_id}objects.search.drop: Clear the entire index for the shop. Called before a full re-index.
Example:
// manifest.json: { "path": "search.js", "type": "search", "search_id": "my-search" }
module.exports = {
"search.query": function (ctx) {
const query = ctx.data.query;
const limit = ctx.data.limit || 20;
// Call your external search API
const resp = fetch("https://api.my-search.com/search", {
method: "POST",
headers: { "Authorization": "Bearer " + settings.api_key },
body: JSON.stringify({ query, limit })
});
const results = resp.json();
// Return product references (hydrated automatically)
ctx.data.products = results.hits.map((hit) => ({ id: hit.product_id, shop_id: hit.shop_id }));
ctx.data.cursor = results.next_cursor || "";
ctx.data.total_count = results.total;
},
"search.index": function (ctx) {
const products = ctx.data.products;
// Send products to your search service for indexing
fetch("https://api.my-search.com/index", {
method: "POST",
headers: { "Authorization": "Bearer " + settings.api_key },
body: JSON.stringify({ documents: products })
});
},
"search.remove": function (ctx) {
const ids = ctx.data.product_ids; // [{id, shop_id}]
fetch("https://api.my-search.com/delete", {
method: "POST",
headers: { "Authorization": "Bearer " + settings.api_key },
body: JSON.stringify({ ids })
});
},
"search.drop": function (ctx) {
fetch("https://api.my-search.com/clear", {
method: "POST",
headers: { "Authorization": "Bearer " + settings.api_key }
});
}
};
To activate a search provider:
- Install the plugin
- Go to Settings > Search
- Click "Set Active" on your provider
- Click "Re-index All Products" to populate the external index
Template Render Hooks
template.before_render: Inject variables into Liquid templates before rendering.
Theme hook tags
Themes can declare named slots with {% hook 'name' %}. Any plugin that exports hook.<name> gets its return value rendered into that slot, in declaration order. The default theme ships these slots that bullion-style dynamic-pricing plugins typically target:
head_start/head_end— top / bottom of<head>on every page.head_endis the right place to load ticker JS that updates DOM prices in real time.body_start/body_end— top / bottom of<body>on every page.
Detecting the page inside a render-time hook — use the dataloader, not the template.
hook.*tag handlers (head_end,body_end, …) receive onlyctx.data.bindings; unliketemplate.before_render, they do not getctx.data.template. To run only on a specific page, branch onctx.data.bindings.dataloader. The order-confirmation page (/checkout/success, rendered asorder.liquid) exposes thecheckout-successdataloader and anorderbinding (totals in cents,items[],number/id) — this is how the bundledgoogle-analyticsplugin fires its client-side GA4purchaseevent with no backend API key. Note the account-sideorder-detail/order-lookuppages also carry anorderbinding but use their own dataloaders, so keying offdataloader === "checkout-success"correctly fires only on the post-checkout thank-you page.
product_after_price— fires on both the product card snippet and the PDP, immediately after the price block. Use this to render a live-price element (<span data-...>) that supplements or replaces the static{{ product.price | money }}. If a product hasprice == 0, the theme hides the static price line entirely, so the hook's output becomes the sole price display — useful for spot-based pricing where the catalog price is meaningless.product_after_add_to_cart,product_after_tags,product_after_form,product_footer— PDP-only extension points.main_start/main_end,footer_content— sectional slots.checkout_payment— the payment method area, used by payment gateway plugins to render their SDK widget. Rendered both at checkout and on the order page when an unpaid order is being paid (a customer completing a created order, or settling/updating the card on a past-due subscription). The same client contract applies in both places: render your element, and on thecheckout_pre_submitwindow event push a Promise toe.detail.promisesand set your collected token one.detail.meta(e.g.e.detail.meta.payment_token = ...). The platform forwards thosemeta[*]fields to yourpayment.create_intent(on the order page via/create-payment-intent), so one element implementation covers first-time checkout, retrying a created order, and subscription card updates.- Scope it to the right dataloaders. This hook (and any
template.before_renderyour plugin uses to inject keys) only fires on pages whose dataloader you declare on the payment script. Declare at least["checkout", "order-detail"]so the element renders on both the checkout and the order/subscription-payment pages; omittingorder-detailis why an element shows at checkout but not when paying an existing order. - The hook owns the active-provider marker — the theme never hardcodes a provider. The hook only renders when your gateway is the shop's active provider, so emit
<input type="hidden" id="payment-provider-input" value="<your-provider>">as part of your returned HTML. Your element JS (and the theme's checkout submit) reads#payment-provider-inputto self-select on the sharedcheckout_pre_submitevent. The server never trusts this value — it charges via the shop's active gateway (GetGateway(shop)); the marker is purely a client-side signal.
- Scope it to the right dataloaders. This hook (and any
checkout_review— a general-purpose slot just above the Place Order button, for non-payment checkout additions (gift message, delivery date, PO number, …). Likecheckout_paymentit renders inside the checkout<form>.
Capturing checkout fields into order.meta
Both checkout slots sit inside the checkout form, so the simplest way to persist a value is to emit an input whose name is meta[<key>] — the server parses every meta[*] form field straight onto order.Meta before checkout.before_create fires. No client JS or e.detail.meta plumbing is required for plain fields (that mechanism exists for values you compute in JS, like a payment token):
// hook.checkout_review — a "this is a gift" message field
module.exports = {
'hook.checkout_review': function (ctx) {
return '<textarea name="meta[gift_message]" placeholder="Gift message…"></textarea>';
}
};
The value round-trips: read it back later with sw.orders.get(id).meta.gift_message (e.g. from an order detail-page widget). The bundled gifting plugin (cli/plugins/gifting) is a complete worked example — a checkout checkbox + message captured to order.meta, plus a Gift button on the order detail page that opens a modal showing the message.
A plugin exporting hook.product_after_price runs on every product card and the PDP — ctx.data.bindings.product is the current product in both contexts.
Print documents — packing-slip hooks
The admin packing slip (order or wired fulfillment) is a server-rendered Liquid document with three hook regions: packing_slip_header, packing_slip_after_items, and packing_slip_footer. Scope a script to the document with the packing-slip dataloader, then read the record from the full bindings — ctx.data.bindings.order (order slip) or ctx.data.bindings.fulfillment (wired-fulfillment slip):
// manifest: { "path": "packing-slip.js", "dataloaders": ["packing-slip"] }
module.exports = {
'hook.packing_slip_after_items': function (ctx) {
var order = ctx.data.bindings.order; // undefined on fulfillment slips
var msg = order && order.meta && order.meta.gift_message;
return msg ? '<div>🎁 Gift: ' + msg.replace(/[&<>"]/g, '') + '</div>' : '';
}
};
The slip also wraps its order totals in a {% block packing_slip_prices %} — wrap it with block.packing_slip_prices (returning '' to hide, or ctx.data.super to keep) to control whether prices print. The bundled gifting plugin uses both: hook.packing_slip_after_items to print the gift message, and block.packing_slip_prices to blank the totals on gift orders so the recipient doesn't see prices.
The print page blocks plugin JavaScript (a strict per-document CSP allows only the platform's own auto-print script), so these hooks/blocks must emit HTML/CSS only — no <script>, no inline onclick. As always, Liquid/print HTML isn't auto-escaped, so escape any UGC you emit.
Hook tags get only
sw.liquidandsw.assets.{% hook %}tag handlers (hook.*) run per-element on the render fast path (e.g. once per product card), so they are deliberately limited tosw.liquid(render a snippet) andsw.assets(asset URLs). The heavier bridges —sw.cache,sw.secrets,sw.products,sw.sql,sw.ledger, etc. (and the globalfetch) — are not reachable inside a hook tag. Do any data-fetching intemplate.before_render(which has the fullsw.*surface) and stage the result intoctx.data.bindings; the tag then reads it back fromctx.data.bindings. This keeps data-loading in the data layer, rendering in the render layer, and the per-card render path fast.
Block wrappers (block.<name>)
Where a hook.<name> appends to a slot the theme author placed, a block.<name> lets you wrap or replace any {% block NAME %}…{% endblock %} region a theme declares — even one with no {% hook %} inside it (see Themes.md → "Template Inheritance"). Export block.<name> (the block's name) and return the HTML to render in its place. The theme's already-rendered block body arrives as ctx.data.super:
module.exports = {
// Wrap: keep the theme's button, then add a financing widget after it.
"block.add_to_cart": function (ctx) {
return ctx.data.super +
sw.liquid.render("./snippets/financing.liquid", ctx.data.bindings);
},
};
- Wrap vs replace. Include
ctx.data.superto keep the theme's markup and add to it (the safe, theme-agnostic default). Omit it to fully replace the block — only do this when you genuinely own that region, since you're then reproducing per-theme markup that can drift. - Composing plugins. If several plugins wrap the same block, they chain in script order: each plugin's returned HTML becomes the next plugin's
ctx.data.super. So a later wrapper that includessupernests around the earlier ones. - Same sandbox as hook tags.
block.*handlers get onlysw.liquid+sw.assets. Load data intemplate.before_render(fullsw.*) and read it fromctx.data.bindings.ctx.data.blockis the block name;ctx.customer,ctx.settings, andctx.planare also provided. - Route/dataloader filtering applies exactly as for
hook.*andtemplate.before_render— declareroutes/dataloaders/templateson the script to scope where the wrapper fires.
The bundled Demo plugin ships a runnable example: cli/plugins/demo/block.js wraps the default theme's {% block add_to_cart %} and — driven by the "Add-to-cart block mode" setting — appends a Buy Now button, fully replaces the Add to Cart button, or leaves the block untouched. The Buy Now button is a plain submit inside the product form, so it reuses the one-click action=buy_now cart flow.
Changing a price: mutate the binding, don't (only) emit HTML
For a price change, the simplest and most theme-faithful approach is not a block at all — mutate product.price in template.before_render and let the theme render its own price markup with the new value:
"template.before_render": function (ctx) {
const p = ctx.data.bindings.product;
if (p) p.price = computeCents(p); // PDP
const list = ctx.data.bindings.products || []; // collection / search cards
for (let i = 0; i < list.length; i++) list[i].price = computeCents(list[i]);
}
product is a live handle to the server-side product, so a write to product.price reaches {{ product.price | money }} everywhere it renders — PDP, cards, cart, JSON-LD. Why this beats emitting price HTML from a block:
- Correct on first paint, correct without JS, no flash — the price is right before anything renders.
- Keeps the theme's price markup (compare-at strikethrough, classes, layout) instead of reproducing it.
Caveats and when you still need a block:
template.before_renderonly. Block/hook tags run mid-render (the surrounding HTML is already produced) and get a minimal sandbox — too late to change a price by mutation. Do it inbefore_render, which runs first with the fullsw.*surface.- Only real fields propagate. Mutation writes through only for real, persisted product fields (
price,compare_price, …). Inventing a property (product.my_flag = …) stays a script-only value the Liquid renderer never sees. - Reaches only products in the page bindings (
product,products[]). Products loaded during render — the{{ "..." | products: }}filter,layout_renderproduct blocks, related-products — aren't inbefore_render, so pair mutation with ablock.pricethat renders the price when the binding wasn't pre-priced. - For live-updating or restructured prices, use
block.priceto wrap the theme's (mutated) markup with whatever the client needs. The bundled bullion-pricing plugin does exactly this:before_rendermutatesproduct.priceto the live spot value;block.pricewraps the theme price in a[data-bullion-product]element so the ticker refreshes it (rendering its own price element only for products mutation didn't reach). For logged-in/B2B tiered pricing, use the native price-level system instead (above) — it applies at every product-load path, including the ones mutation and hooks can't reach.
Building absolute URLs — use ctx.data.bindings.shop.canonical_url. Never reconstruct the storefront origin from the request host: a page can be served on a non-canonical host (a ?shop= preview, or the managed-subdomain mirror of a custom-domain shop), and baking that host into <link rel="canonical">, og:url, or JSON-LD leaks an internal URL into search indexes. The shop binding exposes the resolved public origin for exactly this:
shop.canonical_url— scheme + host, no trailing slash, e.g.https://store.example.com.shop.canonical_host— bare host, e.g.store.example.com.shop.domains— array of the shop's verified custom domains (may be empty).
Both canonical_* fields always resolve to the shop's public domain (custom domain if set, otherwise <subdomain>.shopswired.com). For canonical product/category URLs, prefer the platform-built ctx.data.bindings.og.url and ctx.data.bindings.json_ld where available; build your own only when you need a shape they don't provide, and base it on shop.canonical_url.
Example:
module.exports = {
"template.before_render": function (ctx) {
if (ctx.data.template === "index.liquid") {
ctx.data.bindings.custom_message = "Hello from plugin!";
}
}
};
Blocking or redirecting a page server-side
template.before_render can short-circuit the render entirely — return (don't
just mutate ctx.data) one of these shapes and the platform skips Liquid and
sends your response instead. This is how a fraud/visitor-blocker plugin denies a
page by IP, country, or bot score before any content renders.
module.exports = {
"template.before_render": function (ctx) {
const h = ctx.request.headers; // see "Visitor network data" below
// Block outright — custom status + HTML body:
if (h["X-Geo-Country"] === "XX") {
return { body: "<h1>Not available in your region</h1>", status_code: 451 };
}
// Redirect — { redirect } sends a 302 (override with status_code):
if (h["X-Bot-Score"] && Number(h["X-Bot-Score"]) < 10) {
return { redirect: "/blocked", status_code: 302 };
}
// Otherwise fall through to normal rendering (return nothing / mutate bindings).
}
};
{ body, status_code?, headers? }— replaces the page with your HTML/body.{ redirect, status_code? }— server-side redirect (302 by default).- Both are returned uncacheable (the platform sets
no-store), so a per-visitor block/redirect never poisons the shared page cache. A normal mutate-and-return-nothing render still caches as before. - The hook runs before Liquid, so blocking here costs nothing to render. If
several plugins return a short-circuit, the first non-empty
body/redirectwins.
Loading data for hook tags: Use template.before_render with dataloaders filtering to load data into bindings that hook.* tags can then access. This keeps data-fetching in the data layer and rendering in the render layer.
// manifest.json: { "path": "payment.js", "dataloaders": ["checkout"] }
module.exports = {
"template.before_render": function (ctx) {
// Only fires on checkout pages (due to dataloaders filter)
const key = sw.secrets.get('MY_PUBLISHABLE_KEY');
if (key) {
ctx.data.bindings.my_publishable_key = key;
}
},
"hook.checkout_payment": function (ctx) {
// Access data loaded by before_render — no sw.secrets needed here
const key = ctx.data.bindings.my_publishable_key;
return `<div data-key="${key}">Payment UI</div>`;
}
};
Contributing storefront menu entries: Some storefront menus are data-driven arrays rather than HTML hooks, so plugins can add/remove items cleanly. The Account dropdown (shown to logged-in customers) reads the account_menu binding — an array of { label, url }. Append to it from template.before_render; the theme renders the links (see Themes.md → "The account menu"):
module.exports = {
"template.before_render": function (ctx) {
const menu = ctx.data.bindings.account_menu || [];
menu.push({ label: "Wishlist", url: "/account/wishlist" });
ctx.data.bindings.account_menu = menu;
}
};
Email Hooks
Every transactional email the shop sends (order confirmations, shipping notices, password resets, the sw.email.notify* bridges, etc.) passes through two hooks before the email is delivered. They run server-side in the shop's context — so the full sw.* bridge surface is available, including sw.email.smtpSend.
email.before_render — rewrite the template before Liquid runs
Fires after the email's template and bindings are assembled but before Liquid renders them. Mutate ctx.data to change the outgoing mail:
| Field | Meaning |
|---|---|
to | recipient address |
subject | subject line |
template | the raw Liquid template string about to be rendered — replace it to swap the whole layout |
bindings | the variable map passed to the template (merge in your own keys) |
template_name | which email this is (order_confirmation, shipping_notification, password_reset, welcome, …) |
module.exports["email.before_render"] = function (ctx) {
// Only restyle order confirmations
if (ctx.data.template_name === "order_confirmation") {
ctx.data.subject = "🎉 " + ctx.data.subject;
ctx.data.template = "<h1>{{ shop.name }}</h1>{{ email_content }}";
ctx.data.bindings.promo = "Use SAVE10 on your next order!";
}
};
email.send — rewrite the envelope, or take over delivery
Fires after rendering, immediately before the message is handed to the transport. This is the final manipulation point for the envelope — mutate any of ctx.data's to, cc, bcc, reply_to, from, from_name, subject, html, text. This is where you set a custom sender:
module.exports["email.send"] = function (ctx) {
ctx.data.from = "[email protected]";
ctx.data.from_name = ctx.settings.store_name;
ctx.data.reply_to = "[email protected]";
};
A plugin can also deliver the mail itself and then call ctx.stop() to suppress the platform's built-in send. Either through the shop's own mailbox with sw.email.smtpSend:
module.exports["email.send"] = function (ctx) {
// host/auth/sender support {secret.KEY} expansion — keep credentials in sw.secrets
sw.email.smtpSend({
host: "{secret.SMTP_HOST}", port: 587, secure: "starttls",
username: "{secret.SMTP_USER}", password: "{secret.SMTP_PASS}",
from: "[email protected]", fromName: "My Store",
to: ctx.data.to, subject: ctx.data.subject, html: ctx.data.html
});
ctx.stop("delivered via shop SMTP"); // platform skips its own send
};
…or by calling a transactional mail HTTP API (SendGrid, Mailgun, Postmark, …) with fetch — {secret.KEY} is expanded in fetch URLs, headers, and string bodies too, so your API key never appears in the script:
module.exports["email.send"] = function (ctx) {
fetch("https://api.mailprovider.com/v1/send", {
method: "POST",
headers: { "Authorization": "Bearer {secret.MAIL_API_KEY}", "Content-Type": "application/json" },
body: JSON.stringify({ from: "[email protected]", to: ctx.data.to, subject: ctx.data.subject, html: ctx.data.html })
});
ctx.stop("delivered via mail API");
};
ctx.stop([reason])vsthrow. These are different signals. Throwing rejects the operation — it's logged as an error and counts against the script's failure circuit-breaker; use it for genuine failures (throw new Error("blocked")).ctx.stop()is a clean "I handled this, skip the default action" with no error semantics — use it when your plugin intentionally takes over a built-in behaviour (here, delivery). Onemail.before_render,ctx.stop()cancels the email entirely. Modifications you made toctx.datastill apply alongside astop().
Note: these hooks fire only for shop-context email (
shop_id > 0); platform-level admin mail is not intercepted.
Sitemap & Robots Hooks
The storefront serves a native /sitemap.xml (a sitemap index) with chunked children under /sitemap/, plus a /robots.txt. Core owns the homepage, products, and static theme pages; plugins contribute their own content (blog posts, CMS pages, etc.) through two hooks.
sitemap.urls — contribute URLs to the sitemap
Export sitemap.urls to add your plugin's pages to the sitemap. Set ctx.data.urls to an array of entries — core handles all XML generation, escaping, the 50,000-URL chunking, and wiring each chunk into the index as /sitemap/<plugin_id>-<n>.xml.
module.exports = {
"sitemap.urls": function (ctx) {
const urls = [];
let cursor = "";
do {
const res = sw.records.article.list({
filters: { published: true },
limit: 200,
cursor
});
for (const post of (res.items || [])) {
if (!post.slug) continue;
urls.push({
loc: "/blog/" + post.slug, // relative or absolute
lastmod: post.updated, // optional, ISO or YYYY-MM-DD
changefreq: "weekly", // optional
priority: 0.6 // optional, 0.0–1.0
});
}
cursor = (res && res.cursor) || "";
} while (cursor);
ctx.data.urls = urls; // mutate ctx.data — the return value is ignored
}
};
Each entry's fields:
loc(required) — the page path. A relative path (/blog/x) is prefixed with the storefront's canonical base URL; an absolute URL (https://…) is used as-is. Entries with an emptylocare dropped.lastmod(optional) — last-modified date. Any valuenew Date()can parse is accepted; it's normalized toYYYY-MM-DD.changefreq(optional) —always,hourly,daily,weekly,monthly,yearly, ornever.priority(optional) —0.0–1.0(number or string).
Notes:
- Return only entries, not XML. Core escapes and chunks; a plugin cannot emit raw
<urlset>markup. - Paginate the full set. The hook runs once per sitemap build, so walk all your records (the
do…whilecursor loop above) rather than returning a single page. - Caching. The sitemap is cached for 24h (bypassed in dev mode), so edits appear within a day in production. There is no realtime requirement — search engines refetch on their own schedule.
- Don't list non-crawlable URLs. Skip drafts, redirects, and raw/JSON endpoints; only include canonical, indexable HTML pages. Add a per-record "Include in sitemap" boolean if you want merchants to opt individual pages out.
robots.txt — append robots directives
Export robots.txt to add lines to the generated robots.txt. Set ctx.data.lines to an array of strings; each is appended verbatim (newlines inside a string are stripped so a plugin can't inject extra records). Core already advertises the sitemap and disallows non-indexable routes (cart, checkout, account, search, …).
module.exports = {
"robots.txt": function (ctx) {
ctx.data.lines = [
"Disallow: /preview",
"Disallow: /*?session="
];
}
};
Like every hook, these work by mutating ctx.data (set urls / lines); the function's return value is ignored. Both hooks are ordinary event hooks: put them in a hooks script, or alongside a fetch route handler in the same file — hook discovery picks up the exports from any .js file in the plugin regardless of the script's manifest type.
Plugin Lifecycle Hooks
A plugin can react to its own lifecycle — being switched on, switched off, or
removed from a shop — to provision and tear down external resources (a dedicated
database, a webhook registration, third-party state, etc.). Three hooks fire for
the single plugin undergoing the transition (never broadcast to other
plugins), synchronously, in that shop's context with full sw.* bridges
available:
plugin.activate— fires when the plugin transitions from inactive to active (the moment it actually goes live, including the first activation after a marketplace install). This — not install — is where you provision, because at install time the plugin record exists but is inactive. If the plugin declaresdepends, its dependencies are activated first, so by the time this hook runs every dependency's ownplugin.activatehas already completed.plugin.deactivate— fires when it transitions from active to inactive. Treat this as pause, not delete: keep the merchant's data so re-activating restores it. Stop schedules, release locks, flip a "paused" flag.plugin.uninstall— fires when the plugin is removed, before its record and files become unresolvable. This is the terminal teardown: delete the database, drop secrets, deregister webhooks. It also fires for every installed plugin when the whole shop is closed/deleted (the shop-purge sweep runs it before wiping tenant data), so it is the one place to release per-plugin external resources (Turso/libSQL databases, third-party API keys, external search indexes) that the platform can't reach. Make it idempotent — it may run during an ordinary uninstall or a shop purge.plugin.change_version— fires when the installed version is switched (e.g. a marketplace upgrade or downgrade), after the new version's files are in place. The handler that runs is the new version's, and it receives the previous version so you can run data migrations.ctx.dataaddsold_versionalongside the standardplugin_id/version(the new one):{ plugin_id, version, old_version }. It does not fire when the version is unchanged.
These hooks fire only on the actual state transition, so a settings-only update
never re-runs them. They are best-effort and non-blocking: if your handler
throws (or the plugin doesn't export the hook), the error is logged and the
activate/deactivate/uninstall/change_version still completes — a failed teardown
can't trap a merchant in an un-removable plugin. Because activate can fire again on
re-activation or a version bump, handlers must be idempotent (guard with an
sw.storage flag). Each handler has a generous 60-second budget for network
provisioning, and receives ctx.data = { plugin_id, version } plus the usual
ctx.shop_id and ctx.timeoutRemaining().
// A plugin that gives each shop its own external database.
module.exports = {
"plugin.activate": function (ctx) {
if (sw.storage.get("provisioned")) return; // idempotent: already set up
const dsn = provisionDatabase(ctx.shop_id); // e.g. Turso Platform API via fetch
sw.secrets.set("DB_DSN", dsn); // write-only; used as {secret.DB_DSN}
sw.sql.connect("turso", "{secret.DB_DSN}").batch(MIGRATIONS);
sw.storage.set("provisioned", true);
},
"plugin.deactivate": function (ctx) {
// Preserve the data — the merchant may reactivate. Just mark it paused.
sw.storage.set("paused", true);
},
"plugin.uninstall": function (ctx) {
deleteDatabase(ctx.shop_id); // terminal cleanup
sw.secrets.delete("DB_DSN");
sw.storage.delete("provisioned");
},
"plugin.change_version": function (ctx) {
// Runs the new version's handler; migrate state forward from the old one.
console.log("upgrading", ctx.data.old_version, "->", ctx.data.version);
if (ctx.data.old_version < "2.0.0") {
sw.sql.connect("turso", "{secret.DB_DSN}").batch(V2_MIGRATIONS);
}
}
};
Like every event hook, these can live in any .js file the plugin ships — hook
discovery picks up the exports regardless of the script's manifest type.
Container Job Hook
container.job.completed
Fires after a container job you launched with sw.container.run finishes — done, failed, or canceled — and billing has settled. It runs in a fresh request (the original run() call returned long ago), so this is where you react to a job's result. ctx.data carries:
module.exports["container.job.completed"] = function (ctx) {
const { job_id, status, exit_code, cost_cents, result_url, error, logs } = ctx.data;
if (status !== "done") {
sw.notify.create({ title: `Job ${job_id} ${status}`, body: error || "", category: "jobs", severity: "error" });
return;
}
// result_url is the job's artifact-folder prefix in the installing shop's
// file manager (container-jobs/<job_id>/). List it to read whatever the job uploaded.
const files = result_url ? sw.files.list(result_url) : [];
sw.storage.set(`job:${job_id}`, { cost_cents, files });
};
job_id(string),status,exit_code(int),cost_cents(the actual billed amount — charged to the paying wallet, which may be the developer's),result_url(the artifact-folder prefix in the installing shop's file manager, or"").error(a short failure reason when the job didn't succeed, else""). For the full output, callsw.container.logs(job_id)— logs are fetched live from the runtime (see Logs / debugging).- You can also (or instead) poll
sw.container.get(jobId)from a widget — the hook just lets you react without polling.
Route Handlers (Fetch)
A script registers a storefront HTTP endpoint by declaring "type": "route"
with a method and a route_path in its manifest.json entry, then exporting
a fetch function. The path supports wildcards (/blog/*) and a method of
"ALL" to match any verb.
A route_path may also embed a {settings.KEY} placeholder (the same expansion
available in schedule), so a route prefix can be made shop-configurable. The
placeholder is resolved against the plugin's effective settings (saved values
merged with manifest defaults) at match time, so a setting with a default
works out of the box. For example, declare a path_prefix setting (default
/blog) and use "route_path": "{settings.path_prefix}" plus
"{settings.path_prefix}/*". The resolved value is also available to the handler
as ctx.settings.path_prefix — read it there to strip the prefix and recover the
trailing slug rather than hard-coding a segment index.
{
"scripts": [
{
"path": "api.js",
"type": "route",
"method": "GET",
"route_path": "/my-endpoint"
}
]
}
// api.js
module.exports.fetch = function (ctx) {
const req = ctx.request;
// req.method, req.url, req.path, req.query, req.json()
return {
status: 200,
body: { success: true }
};
};
Don't confuse
route_pathwith the script-levelroutesarray. Aroute_path(withtype: "route"+method) registers an HTTP endpoint. The"routes": ["/product/*"]array on a hook script is only a filter — it restricts which storefront routes the script's hooks (e.g.template.before_render) run on, and registers no endpoint.
A route handler's ctx also carries ctx.shop — { id, name, currency, canonical_url, canonical_host }. Use ctx.shop.canonical_url (the public
storefront origin — custom domain if set, otherwise <subdomain>.shopswired.com,
no trailing slash) to build absolute, canonical links in feeds / sitemaps /
.well-known documents, rather than reconstructing the origin from the request
host (which may be a non-canonical managed mirror). ctx.shop.currency is the
shop's configured currency code.
Storefront POST routes need a CSRF token. Any state-changing request on the storefront origin (
POST/PUT/PATCH/DELETE) — including a call to your own pluginroute_pathfrom theme JS — is rejected 403 unless it carries the CSRF token. Send the value of the (non-HttpOnly)csrf_tokencookie either as anX-CSRF-Tokenheader or acsrf_tokenform field; it must match the cookie. (This is the storefront scheme — distinct from the widget origin, which uses theX-CSRFheader thatwindow.sw.fetchattaches for you.) Read it client-side withdocument.cookie.split('; ').find(r => r.startsWith('csrf_token='))?.split('=')[1], or from a hidden input rendered by<csrf_tag />. AGETroute needs no token — useGETfor read-only endpoints (e.g. a price/availability poll) to avoid CSRF entirely.
Reading the request body
ctx.request exposes a browser-style body API. The buffering methods
(text() / arrayBuffer() / json()) cover the common small-payload cases; for
uploads use formData(), which parses multipart/form-data and surfaces file
parts as streamable File objects.
req.text(); // the raw body as a string
req.arrayBuffer(); // the raw body as bytes
req.json(); // the body parsed as a JSON object (null if empty/not JSON)
req.body; // a streaming handle, read straight off the wire (zero-copy) —
// pipe a big raw upload straight to storage, nothing buffered:
// sw.files.upload("imports/data.bin", req.body)
const fd = req.formData(); // a FormData object (parses urlencoded + multipart)
fd.get("email"); // first value for a field (a string), or null
fd.getAll("tags"); // every value for a field
fd.has("photo"); // boolean
for (const [name, value] of fd.entries()) { /* fields + files */ }
fd.keys(); fd.values(); // same shapes as the WHATWG FormData API
The body is consume-once, like a real Request: read it either with the
buffering methods or by streaming req.body — not both. The buffering methods
share a single read, so req.json() then req.text() is fine; streaming
req.body after buffering (or buffering after streaming) throws. req.body is for
large raw payloads you don't want in memory; reach for it instead of
arrayBuffer() when size matters.
File uploads. fd.file(name) (or fd.get(name) on a file field) returns a
File object — a readable just like a fetch response:
const f = req.formData().file("photo");
if (f) {
// f.name (filename), f.type (content-type), f.size (bytes)
// f.body streams the part; f.text() / f.bytes() / f.json() buffer it (capped at 1 MB)
const saved = sw.files.upload("uploads/" + f.name, f.body, f.type); // streamed, never buffered whole
// saved.path / saved.url / saved.public_url?
}
Large multipart uploads spill to temp files during parsing, so a big file is never
held whole in memory — stream it to storage via f.body rather than calling
f.bytes(). For multipart/form-data read fields and files through formData();
text() / arrayBuffer() / json() / req.body apply to non-multipart bodies.
Streaming a large response body (write)
A normal fetch return buffers the entire body in memory before it ships —
fine for small JSON/HTML, but a large generated document (a product feed, a big
CSV, a sitemap of every URL) can exhaust the available memory and the run is
stopped. For those, return a write(out) callback instead of a body: the
host invokes it with a streaming sink and pushes each chunk to the client as you
produce it, so only one chunk is resident at a time. It's the output-side
counterpart to consuming a large upload through req.body.
module.exports.fetch = function (ctx) {
return {
status: 200,
headers: { "Content-Type": "application/xml; charset=utf-8" },
write: (out) => {
out.write('<?xml version="1.0"?>\n<rss version="2.0">\n');
// page the catalog and write per item; nothing accumulates
sw.products.list({ limit: 200 }).items.forEach((p) => {
out.write(` <item><title>${p.title}</title></item>\n`);
});
out.write('</rss>\n');
}
};
};
The out sink:
out.write(chunk)— append astring(UTF-8), anArrayBuffer, a typed array (Uint8Array), or a readable handle (the.bodyof afetch(...),sw.gdrive.download(...), orsw.files.download(...)result). A readable is piped straight through — drained and closed — with nothing held whole in memory, so you can proxy a large remote/stored file to the client at constant memory. It's the output-side mirror ofsw.files.upload(path, req.body). Chunks are buffered and auto-flushed at ~32 KB.out.flush()— optional; force-push the buffered bytes to the client now (e.g. emit the document head immediately so the browser starts rendering).
// Proxy a private storage object to the client without buffering it:
module.exports.fetch = function (ctx) {
const file = sw.files.download("private/report.pdf");
return {
status: 200,
headers: { "Content-Type": "application/pdf" },
write: (out) => { out.write(file.body); } // streamed end-to-end
};
};
Rules:
- Set
Content-Typeinheaders— it defaults totext/plain; charset=utf-8. writewins. Ifwriteis a function it takes precedence;bodyandtemplateon the same response are ignored.- Headers and status are sent once, before the first chunk. Once any byte has flushed the status line is locked — you cannot change it mid-stream.
- A throw after the first flush can't become an error response. The
connection is simply truncated and the partial body is logged server-side.
Because chunked responses carry no
Content-Length, a consumer must treat a truncated feed as a failure. If you need all-or-nothing semantics, buffer the whole thing and returnbodyinstead. - Streamed responses are never page-cached (length is unknown and there's no materialized body to store).
- A fetch route gets a short inline budget (~30s), not the full request deadline.
The same budget applies to a widget render and the synchronous "Run" test button.
These run on shared platform capacity, so a long inline run is interrupted to
protect the platform for everyone. Do not do long/expensive work inline. If a request needs a slow or large artifact,
generate it in a background task and serve the stored file: enqueue
sw.task.bgto build it andsw.files.upload(...)the result, then have the route return (or302to) that file once it exists — pollsw.files/a status flag on subsequent requests. A route that blows the budget is interrupted (see Memory & timeout limits below). UseGETfor streaming endpoints (read paths; no CSRF token needed).
Edge-caching a route response (cache)
By default a route response is never edge-cached — every request runs your
script. For a page whose HTML is identical for every anonymous visitor (a blog
post, a public listing, a marketing page), add cache: true to the response
to serve it from the CDN edge with the same stale-while-revalidate policy regular
storefront pages use. The platform handles the rest:
module.exports.fetch = function (ctx) {
return {
template: "./templates/article.liquid",
cache: true, // edge-cache for anonymous visitors
bindings: { article }
};
};
cache works on buffered responses — both template+bindings and
status+body+headers. Pass cache: { max_age: 300 } to widen the freshness
window (seconds); a bare true uses the platform default (~10s fresh, then served
stale for up to 7 days while it revalidates in the background). The edge cache keys
on the full URL including the query string, so paginated/filtered variants
(?cursor=…, ?page=…) cache as distinct entries.
The backend enforces the safety rails — opting in requests caching, it never forces it:
- Logged-in customers always get a fresh, private response. A request carrying
a
customer_tokenis never edge-cached (personalized pricing). Only anonymous visitors share the cached page. GETonly, and never in dev mode or theme preview (you always see fresh output while editing).- Streamed responses (
write) are never cached regardless ofcache.
You own correctness-of-variance.
cacheis safe only when the response depends on nothing beyond the URL and the anonymous/logged-in split. If your page varies by a custom cookie, geo (X-Geo-*), or any per-visitor signal, don't setcache— the edge would serve one visitor's page to the next. Prefer settingcachefrom a plugin setting so the store owner can disable it (the bundled Blog plugin does this via its Edge Cache Blog Pages checkbox).
Visitor network data (IP, geo, bot)
ctx.request is available in fetch route handlers and in every event hook
(template.before_render, checkout.before_create, order.*, …) — same shape
everywhere: { method, url, path, proto, headers, query }. The visitor's
network signals ride in ctx.request.headers as trusted, edge-resolved values:
| Header | Meaning |
|---|---|
X-Real-Ip | Visitor IP (always present; resolved from the edge connection). |
X-Geo-Country | ISO country code, e.g. US. |
X-Geo-Region | Region/state name. |
X-Geo-City | City name. |
X-Geo-Latlong | "lat,long" (single header), e.g. "37.77,-122.42". |
X-Geo-Postal | Postal/ZIP code. |
X-Bot-Score | Edge bot-detection score 1–99 (low ⇒ likely bot). |
X-Verified-Bot | "1" for a known-good crawler (Googlebot, etc.), else "0". |
User-Agent | The raw UA string (passes through untouched). |
const h = ctx.request.headers;
const ip = h["X-Real-Ip"];
const country = h["X-Geo-Country"];
const isBot = h["X-Verified-Bot"] === "1" || Number(h["X-Bot-Score"] || 100) < 30;
Trust & availability.
- The platform overwrites these at the edge on every request, and strips any
raw client-supplied
X-Geo-*/X-Bot-*/X-Real-Ipcopies before re-injecting the trusted edge value — so a visitor cannot forge them.X-Real-Ipis resolved from the unforgeable edge connection. Geo/bot are trustworthy to the degree that traffic reaches the platform through the edge — the same edge-fronted assumption all IP-based features already rely on. X-Real-Ipis always present. Geo and bot headers only exist behind the edge (production); in localclidev they're absent — treat a missing header as "unknown" and fail open.X-Bot-Score/X-Verified-Botalso require the edge's bot-detection feature; without it they're absent.checkout.before_createnow carriesctx.request, so a checkout blocker can decide on IP/country/bot in addition toctx.data.orderemail/phone. Throw{ error, redirect_url }to block (see the checkout hook section).
Note on Customer Context:
The execution context (ctx) provides information about the logged-in user differently depending on the hook type:
fetchroute handlers: Receivectx.customer_id(the ID of the logged-in customer, or0if guest). Becausefetchhandlers run with full access to the Bridge API, you can query the full customer record if needed viasw.customers.get(ctx.customer_id).- Render Hooks (
template.before_render,hook.*): Receive the full customer object asctx.customer(e.g.,ctx.customer.id,ctx.customer.email). These hooks operate in a restricted render environment where having the pre-loaded customer object is more helpful.
Dashboard Widgets
Plugins can ship widgets that render inside the ShopsWired admin panel — on the dashboard, as a left-menu page, or both. Widgets render server-side using the same exports.fetch(ctx) convention as route handlers, then are served into a sandboxed iframe with strict CSP and its own short-lived, scoped session. There is no client-side SDK to learn: write JavaScript + Liquid + fetch, exactly like a route plugin.
Manifest
A widget is declared in two places in manifest.json:
- A
scripts[]entry withtype: "widget"mapping the script to a widget id. - A
widgets[]array entry describing the widget shown in the dashboard gallery and (optionally) the left-menu.
{
"scripts": [
{
"path": "widgets/recent-reviews.js",
"type": "widget",
"widget_id": "recent-reviews"
}
],
"widgets": [
{
"id": "recent-reviews",
"name": "Recent Reviews",
"icon": "⭐",
"description": "Shows the most recent approved customer reviews.",
"source": {
"type": "internal",
"script": "widgets/recent-reviews.js"
},
"permissions": ["read:records:review"],
"config_defs": [
{ "key": "limit", "type": "number", "label": "Number of reviews", "default": 5 }
],
"defaults": {
"width": "1/2",
"rows": 4,
"config": { "limit": 5 }
},
"placement": {
"dashboard": true,
"page": { "menu": true, "group": "Reviews" }
}
}
]
}
Schema fields:
id/name/icon/description— gallery card display.source.type—"internal"(this plugin renders the widget in its own server-side script).source.script— for internal widgets, the path that matches thescripts[].pathdeclared above.permissions— display-only string list shown in the gallery (e.g."Reads: products, orders"). Enforcement happens via the existing per-pluginsw.*scoping; this field is documentation for the shop admin.config_defs— per-instance config UI schema, same shape assettings[](text,number,checkbox,select,color,textarea).defaults.width— one of"1/4","1/3","1/2","2/3","full".defaults.rows— initial height in grid rows (1 row ≈ 80px). The dashboard grid snaps widgets to row boundaries so layouts stay aligned. Resize a widget to any number of rows from the config panel.defaults.config— initial config values applied when the widget is added.placement.dashboard— show in the dashboard gallery (default true).placement.page.menu— also expose as a full-page entry in the left sidebar. Group adjacent page widgets under a header viaplacement.page.group.placement.detail— render the widget on an entity detail page, bound to one record (see Detail-page widgets below).roles/placement.page.roles— restrict a widget/page to specific built-in shop roles (role-name → level map). Note: custom-record access has moved to a permission-keyedpermissionsmap (see Gating custom records with permissions); widget/pagerolesremain role-name based for now.styles—""(default) applies the bundled classlesssw-widgetstylesheet to the iframe body."none"opts out so the widget controls its own styling (the html/body reset still ships).
Detail-page widgets (tabs & buttons)
A widget can also appear on an entity's admin detail page — the order, product, customer, coupon, fulfillment, or custom-record view — bound to that one record. The same fetch(ctx) script renders it; the only new thing it sees is ctx.widget.entity = { type, id } identifying the record. Use it to load the entity (sw.orders.get(ctx.widget.entity.id), etc.) and render record-specific UI: a fulfillment panel on an order, a supplier tab on a product, a loyalty summary on a customer.
Declare it with one or more placement.detail entries (a widget may have several — e.g. a tab and a button for the same entity):
"placement": {
"detail": [
{ "entity": "order", "mode": "tab", "label": "Shipments" },
{ "entity": "order", "mode": "button", "label": "Refund", "variant": "danger", "size": "md" }
]
}
entity—"order","product","customer","coupon","fulfillment","subscription", or a custom record type as"custom:<type>"(e.g."custom:rfq"). (The viewer must have read access to that entity, or the widget is hidden.)mode—"tab"(default) adds the widget as a first-class tab in the detail page's tab bar, sitting beside the native tabs (Details / Related / Memos) and deep-linkable via?tab=app:<pluginId>:<widgetId>;"button"adds an action button to the page's standard button bar that opens the widget in a modal.label— tab title / button text (defaults to the widgetname).icon— optional leading glyph (button or tab).variant(button only) —"primary","secondary"(default), or"danger".size(button only) — modal size"sm","md"(default), or"lg".roles— optional per-placement role gate (role-name → level), applied in addition to the widget-levelroles.condition— optional"<setting_key> == <value>"expression (same syntax as a settings field'scondition). The tab/button only appears — and can only be launched — when the plugin's effective settings satisfy it, so a placement can be toggled by a merchant setting. Evaluated server-side against the merged settings (manifest defaults included), e.g."condition": "enable_radar == true". Empty/omitted = always shown. The bundledstripe-paymentplugin uses this to show a Risk tab on the order page only when the merchant turns on itsenable_radarsetting.
Tabs are lazy — a tab's iframe is only launched the first time it's opened. A button costs nothing until clicked.
Closing a button modal and refreshing the page. A button widget that finishes an action (e.g. a refund succeeded) calls sw.close() to dismiss its modal. By default the host then re-fetches the underlying record so the detail page reflects the change; pass sw.close({ refresh: false }) to skip the reload. sw.close() is a no-op for tab/page/dashboard widgets. A tab widget that mutated the record can also call sw.close({ refresh: true }) to ask the page to reload (there's no modal to dismiss).
Client scripts that don't render on the server can read the bound record from sw.entity ({ type, id }, or null outside a detail page) — the client-side mirror of ctx.widget.entity.
The fetch(ctx) signature
Widget scripts export fetch(ctx). The same function handles both the initial GET that renders the iframe and any in-iframe AJAX — branch on ctx.request.method and ctx.request.path exactly as in a route plugin. Most widgets only need the GET render because the dashboard hover toolbar provides a refresh action that reloads the iframe; reach for in-iframe AJAX (window.sw.fetch) only for user-initiated interactions inside the widget body (filters, row-level actions, etc.).
// widgets/recent-reviews.js
module.exports.fetch = function (ctx) {
const limit = (ctx.widget.config && ctx.widget.config.limit) || 5;
const reviews = sw.records.review.list({ filters: { status: 'Approved' }, limit });
return sw.liquid.render('./widgets/recent-reviews.liquid', {
reviews: (reviews && reviews.items) || [],
ctx
});
};
ctx extends the normal route handler context with a widget field carrying per-instance state:
ctx.widget.id— the widget schema id (e.g."recent-reviews").ctx.widget.key— the stable per-widget identifier within the containing dashboard. Empty when the widget is rendered as a page. Use this forsw.storagekeys when you want isolated state per widget instance.ctx.widget.dashboard_id— id of the containing dashboard (only set for dashboard widgets; omitted for page widgets).ctx.widget.page—truewhen rendering as a left-menu page (no dashboard membership). Per-instance config falls back todefaults.configsince there's no dashboard to read from.ctx.widget.entity— for detail-page widgets, the record the widget is bound to:{ type, id }wheretypeis"order","product","customer","coupon","fulfillment","subscription", or"custom:<type>". Omitted for dashboard/page widgets. Server-trusted (it's provided by the platform, server-side), so load the record straight from it.ctx.widget.placement— where the widget is rendering:"dashboard","page","tab", or"button". Lets one widget adapt its UI to context — e.g. show a Close button (sw.close()) only whenplacement === "button"(a modal), or hide it in a"tab". Provided by the platform, so it's server-trusted.ctx.widget.config— the merged config (defaults + per-instance overrides set via the config panel).ctx.widget.user—{ id, email, role, permissions }of the admin currently viewing the widget.ctx.widget.shop—{ id, name, currency, canonical_url, canonical_host }.canonical_urlis the shop's public storefront origin (custom domain if set, otherwise<subdomain>.shopswired.com), no trailing slash — use it to display/build absolute storefront links from an admin widget (the widget renders on an admin origin, so the request host is not the storefront).ctx.role— the viewer's built-in shop role (owner/admin/staff). Also underctx.widget.user.role.ctx.permissions— array of this plugin's declared permissions the viewer holds (de-namespaced). Also underctx.widget.user.permissions. Gate behavior on these server-side, e.g.ctx.permissions.includes('view_orders').ctx.widget.csrf— random CSRF token bound to the session cookie; inject into your Liquid templates for any POST endpoint your widget exposes.ctx.widget.base— string path-prefix shared by every URL under this widget instance (e.g./w/1/reviews/recent-reviews/42). The iframe's CSP only allowsconnect-src 'self', so any in-iframe AJAX must hit this prefix — the bundledwindow.sw.fetchhelper does this for you (see Built-in styles and helpers below).ctx.widget.url(path)— same asbasebut as a function for scripts that prefer a call:sw.fetch(ctx.widget.url('refresh')). Equivalent toctx.widget.base + '/' + path. Not callable from Liquid (Liquid has no method-call syntax).
All other sw.* bridges (records, products, orders, customers, storage, liquid, assets, etc.) work as in any other plugin script.
Linking your own static assets — always use sw.assets.url(), never a hardcoded /plugin-assets/... path. A widget renders on its own origin (w-<hash>.shopswired.com in prod), which carries no shop in the hostname, so a literal /plugin-assets/<pluginID>/file.js cannot resolve the shop and 404s. sw.assets.url('assets/grapes.min.js') returns a shop-scoped, same-origin URL that resolves correctly on the widget origin (and on the storefront, where the same call is also the right way to reference an asset). Loaded same-origin, it satisfies the widget CSP's script-src 'self' / style-src 'self' without any cross-origin allowance — so self-hosted CSS/JS/fonts just work. Resolve the URL server-side in fetch(ctx) and seed it into your markup (e.g. a window.MY_ASSETS object or a Liquid binding); don't reconstruct the path in client JS.
Return values
The dispatcher accepts three return shapes from fetch(ctx):
- String — treated as HTML. On the initial GET render, the dispatcher wraps the string in the iframe shell (CSS reset, postMessage shim, CSP headers). On subsequent requests, the string is returned as-is.
{ html, status?, headers? }— explicit HTML response with no wrapper. Use this when your widget controls the full document.{ json, status?, headers? }— JSON response. Use for AJAX endpoints called from inside the iframe.
Liquid templates
Templates resolve relative to the plugin root with ./ prefix. The iframe shell wraps your output in a body that already has class="sw-widget", so the bundled stylesheet (see below) styles everything you write to match the admin theme — just emit semantic HTML, no wrapper or inline <style> blocks needed:
<header><h3>Recent Reviews</h3></header>
{% if reviews.size == 0 %}
<p class="sw-muted">No approved reviews yet.</p>
{% else %}
<ul>
{% for r in reviews %}
<li>
<div class="sw-row">
<strong>{{ r.data.reviewer_name | default: 'Anonymous' }}</strong>
<small>★ {{ r.data.rating | default: 0 }}</small>
</div>
{% if r.data.content %}<p>{{ r.data.content }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
To opt out of the foundation styling (e.g. for widgets that ship their own design system or embed a third-party UI), declare "styles": "none" on the widget in manifest.json. The shell still loads sw-widget.css for the html/body reset, but the body has no class, so the classless rules don't apply.
Refresh is provided by the host: the hover toolbar (dashboard widgets) and the page header (menu-page widgets) both display a ↻ button that reloads the iframe and re-renders from fetch(ctx). Widgets do not need to ship their own refresh control.
For user-initiated AJAX inside the widget body (filter changes, row-level actions), use window.sw.fetch — it auto-prefixes the widget base path, attaches the CSRF token on state-changing requests, and JSON-encodes plain-object bodies. The X-CSRF header is mandatory on POST/PUT/PATCH/DELETE; the dispatcher rejects requests with a missing or mismatched token (sw.fetch adds it for you).
Built-in styles and helpers
Every wrapped widget response auto-loads two shared assets from the widget host:
sw-widget.css— a classless foundation. The iframe shell appliesclass="sw-widget"to<body>by default, so every descendant standard HTML element (h1–h6,p,ul/ol/li,dl/dt/dd,table,button,input/select/textarea,header/footer,code/pre,hr,blockquote, …) inherits the admin theme styling — zero classes to memorise. Three opt-in utility classes are available for layouts native HTML can't express:.sw-grid— auto-fit gridrepeat(auto-fit, minmax(120px, 1fr))withgap: 10px..sw-row— flex row,align-items: center,justify-content: space-between,gap: 8px..sw-muted— secondary text color.
Design tokens are exposed as CSS custom properties on
.sw-widgetso a widget can override them per-instance without writing a stylesheet:--sw-primary,--sw-bg,--sw-surface,--sw-text,--sw-text-light,--sw-border,--sw-radius,--sw-accent(defaults to--sw-primary),--sw-success,--sw-warning,--sw-danger. Overrides cascade — wrap content in<div style="--sw-accent: #10b981">…</div>to retint a subtree.Opt out per widget by declaring
"styles": "none"on the widget inmanifest.json— the body gets no class so the classless rules don't apply, and the widget controls its own styling. The reset (zero margins on html/body, transparent background) is still applied.sw-widget.js— exposes a smallwindow.sw.*helper API and smooths over the iframe sandbox so ordinary markup behaves natively.sw.fetch(path, opts)— the core helper:- Prepends
ctx.widget.baseto any non-absolutepath. - On
POST/PUT/PATCH/DELETE, attachesX-CSRF: ctx.widget.csrf. - If
opts.bodyis a plain object (notFormData/URLSearchParams/Blob/ArrayBuffer/string), serialises it as JSON and setsContent-Type: application/json. - Returns the
Promise<Response>; callers do.json()/.text()/.okthemselves.
<button onclick="markRead()">Mark all read</button> <script> async function markRead() { const res = await sw.fetch('/mark-read', { method: 'POST', body: { all: true } }); if (res.ok) location.reload(); } </script>window.sw.baseandwindow.sw.csrfare also exposed for non-fetch use cases.Sandbox smoothing. The iframe is sandboxed
allow-scripts allow-same-origin— deliberately withoutallow-formsorallow-modals— so native<form>submission andalert()/confirm()/prompt()are blocked by the browser. The runtime fills the modal gaps with in-DOM helpers (below). It does not try to rescue native form submission:Never rely on native
<form>submit. The browser blocks it — and logs "Blocked form submission … the form's frame is sandboxed" — at the moment the submission is initiated, before the cancelablesubmitevent fires, so it can't be intercepted from the page. Give every buttontype="button"and POST with thesw-postdirective (which serializes the nearest<form>) or callsw.fetchyourself. Putonsubmit="return false"on the<form>so a stray Enter key can't trigger a native submission either.<form onsubmit="return false"> <input name="sku" required> <button type="button" sw-post="action" sw-target="#list">Add</button> </form>sw.toast(message, type)shows an in-DOM toast (type:okdefault,error,warn).window.alertis shimmed to call it.await sw.confirm(message, opts)resolvestrue/falsefrom an in-DOM dialog (opts:okText,cancelText,danger) — the async replacement for the blockedconfirm().await sw.pickFile(opts)/await sw.pickRecord(opts)/await sw.pickFolder(opts)— open the admin's native pickers from inside a widget (the iframe can't reach the shop's file library or records itself). The host renders the picker and posts the selection back.sw.pickFile({ multiple, root })resolves to a file URL string (orstring[]whenmultiple), ornullif cancelled.rootoptionally restricts the browser (public,private, …).sw.pickRecord({ model, multiple })resolves to{ id }(or{ ids: [...] }whenmultiple), ornull.modelisproduct,customer,order,coupon, orcustom:<type>(defaults toproduct).sw.pickFolder({ root })lets the user choose a destination folder (e.g. where to write an export). It resolves to a root-prefixed folder path string like"public/exports", ornullif cancelled — directly usable as asw.filesprefix (sw.files.upload(folder + "/report.csv", data)).rootoptionally restricts the browser to a single root.sw.pickLayout({ value, themeId, field })opens the admin's visual layout builder — drag-and-drop rows/columns/blocks (heading, text, rich text, image, button, products, plugin hook, custom HTML) with a live storefront preview.valueseeds it with an existing layout (the same{ version, layout: [...] }object the builder produces).themeId/fieldpick the theme its preview renders against (default: the shop's active theme). Resolves to the edited layout object, ornullif cancelled. Store the result yourself (e.g.sw.storageor your own record) and render it on the storefront with thelayout_renderLiquid filter (see Themes.md).
<button type="button" onclick="chooseImage()">Choose image…</button> <script> async function chooseImage() { const url = await sw.pickFile({ root: 'public' }); if (url) document.getElementById('img').value = url; } async function chooseProduct() { const sel = await sw.pickRecord({ model: 'product' }); if (sel) console.log('picked product', sel.id); } async function exportCsv() { const folder = await sw.pickFolder({ root: 'public' }); if (folder) await sw.fetch('/export', { method: 'POST', body: { folder } }); } async function editLayout() { const layout = await sw.pickLayout({ value: saved /* or omit */ }); if (layout) await sw.fetch('/save-layout', { method: 'POST', body: { layout } }); } </script>Opening links —
sw.open(url, opts?)and automatic anchor handling. The iframe is sandboxed withoutallow-popups/allow-top-navigation, sotarget="_blank"andwindow.openare silently swallowed by the browser. The runtime intercepts anchor clicks for you: a link that opens a new tab (target="_blank"), points off the widget origin, or uses amailto:/tel:/sms:scheme is handed to the host, which opens it in a new tab (with the opener severed). Plain same-origin links with notargetstill navigate the iframe in place as usual — so internal widget navigation is unaffected. Adddata-sw-no-opento any anchor to opt it out of interception.<a href="https://docs.example.com" target="_blank">Open docs</a> <!-- new tab via host --> <a href="?view=settings">Settings</a> <!-- navigates iframe in place -->Call
sw.open(url, opts)directly when you open a link from JS rather than a click (e.g. after an async action).urlmay be relative (resolved against the widget) or absolute. The host only openshttp:/https:/mailto:/tel:/sms:URLs —javascript:and other schemes are refused, so a widget can never run script in the admin origin through this path.const res = await sw.fetch('action', { method: 'POST', body: { action: 'export' } }); const { download_url } = await res.json(); sw.open(download_url); // opens the signed URL in a new tabsw.close(opts?)andsw.entity— for detail-page widgets.sw.entityis the bound record{ type, id }(ornullelsewhere).sw.close()dismisses the host modal amode:"button"widget renders in and, by default, asks the host to re-fetch the underlying record; passsw.close({ refresh: false })to skip the reload. It's a no-op for tab/page/dashboard widgets (a tab can still callsw.close({ refresh: true })to request a page reload after it mutates the record).Native
alert/confirm/promptare overridden to a safe default (so they stop logging sandbox errors) andconsole.warnonce pointing at thesw.*equivalents.confirm()/prompt()can't block synchronously withoutallow-modals, so always use the asyncsw.confirm.form.reportValidity()is fine — it isn't a modal.
Toast/dialog positioning caveat. They render inside the widget iframe, which the host sizes to its full content height (no internal scroll), so a
fixedtoast anchors to the top of the widget — visible for typical short widgets, but possibly above the fold on a very tall one. For guaranteed-visible feedback in a long page, also render an inline status line near the action.- Prepends
Reactive directives (sw-*)
For interactive widget UI you can write markup attributes instead of hand-wiring
DOM events. sw-widget.js scans the document on load (and re-scans any HTML it swaps
in), so directives "just work" in server-rendered Liquid. There is no framework to
bundle and nothing to import — it is a deliberately small layer (think a pinch of
Alpine for local state, a pinch of htmx for fragment swaps). Call sw.init(el) after
injecting your own HTML to activate directives inside it.
Local state — no server round-trip. Put initial state as JSON on a root element
with sw-data; everything inside that root shares it:
| Directive | Effect |
|---|---|
sw-data='{"mode":"in","qty":0}' | Declares a reactive state root. |
sw-model="qty" | Two-way binds an <input>/<select>/<textarea> to state.qty (numbers/checkboxes coerced). |
sw-show="mode == 'in'" | Shows/hides the element by a truthy expression. |
sw-text="qty * price" | Sets textContent from an expression. |
sw-html="..." | Sets innerHTML from an expression. |
sw-class="on: mode=='ship'; warn: qty<0" | Toggles each named class by its expression (;-separated class: expr pairs). |
sw-on:click="qty = qty + 1" | Runs a statement on the event (any DOM event after sw-on:), then re-renders. $event is in scope. |
<div sw-data='{"mode":"receive","qty":0}'>
<select sw-model="mode">
<option value="receive">Receive</option>
<option value="ship">Ship</option>
</select>
<div sw-show="mode == 'receive'">…receiving fields…</div>
<div sw-show="mode == 'ship'">…shipping fields…</div>
<input type="number" sw-model="qty">
<p>You are moving <span sw-text="qty"></span> units.</p>
</div>
Expressions are plain JavaScript, evaluated with the root's state object in scope (so
mode == 'ship', qty * price, obj.items.length all work). A bad expression is logged
to the console and skipped, never thrown. sw.state(el) returns the nearest root's state
object and sw.refresh(el) re-renders it, if you need to drive state from your own JS.
These directives are a deliberately tiny convenience layer, not a framework — there is no list rendering, computed values, or component model. The widget CSP allows
'unsafe-eval'(see Sandbox and security), so for anything more advanced just load the reactive framework of your choice (Alpine, petite-vue, Vue, …) from your widget template and use it directly.
Fragment swaps — replace the full-page location.reload() with a partial update.
The request carries an X-SW-Partial: 1 header so your fetch(ctx) can return only
the fragment as { html: '…' } (a plain string return on a first GET is wrapped in the
full widget page — see Return values):
| Directive | Effect |
|---|---|
sw-get="?view=products" | Issues a GET (default trigger: click). |
sw-post="action" | Issues a POST; serializes the nearest <form> (or sw-vals='{…}') as the body. |
sw-target="#content" | CSS selector for where the response goes (default: the element itself). |
sw-swap="innerHTML" | innerHTML (default), outerHTML, beforeend, afterbegin, or none. |
sw-confirm="Delete this?" | Gates the request behind sw.confirm() first. |
sw-push="?view=products" | Updates the iframe URL after a successful swap (no reload). On a page widget this also deep-links — see Page placement. |
<nav>
<a sw-get="?view=products" sw-target="#content">Products</a>
<a sw-get="?view=stock" sw-target="#content">Stock</a>
</nav>
<div id="content"><!-- swapped here --></div>
A JSON response with { error } is toasted instead of swapped; otherwise a sw:success
(JSON) or sw:swapped (HTML) event bubbles from the trigger. Swapped HTML is re-scanned,
so nested sw-* directives in the fragment come alive automatically.
Attribute editor — sw-attrs="fieldName" turns a container into an add/remove
key/value editor that serializes to a hidden <input name="fieldName"> holding a JSON
object, so a non-technical user edits attributes as rows instead of hand-writing JSON.
Seed existing values with sw-attrs-value='{…}' (or a pre-existing hidden input):
<form onsubmit="return false">
<div sw-attrs="attributes" sw-attrs-value='{"category":"Widgets"}'></div>
<button type="button" sw-post="action" sw-target="#list">Save</button>
</form>
Page placement
Setting placement.page.menu = true registers the widget as a left-sidebar entry at /admin/widget/{plugin_id}/{widget_id}. Adjacent widgets that declare the same placement.page.group collapse under a shared group header (same behaviour as custom_records.group). Page widgets set ctx.widget.page = true and fall back to defaults.config — there is no per-instance config persistence for page widgets.
Deep-linking (automatic). Page widgets are single iframes on a separate origin, so the admin address bar can't see where you've navigated inside one. The runtime bridges this: whenever your widget changes its location — a real navigation, or an sw-push swap, or a pushState/hashchange from a framework you loaded — sw-widget.js reports the location (everything after the widget root, treated as opaque) to the admin, which mirrors it into a ?route= query param. On a refresh or a shared link the admin replays it back so the widget reopens at the exact same place. You get this for free by changing your widget's URL; nothing to wire up, and the platform never parses your URL scheme (so ?view=, ?tab=, path segments, your own #hash — all work). Dashboard-card widgets don't deep-link (they have no route of their own).
Sandbox and security
- The iframe is served from a separate, dedicated origin (a different hostname from the admin), so the cross-origin boundary protects the admin's cookies and storage — a widget is partitioned away from the admin and can never reach it.
- A strict CSP on every widget response means the iframe can only
fetchback to its own widget endpoint — even with full script execution, a widget cannot call or exfiltrate to any other origin from the browser (reach external services server-side viasw.fetch). Inline scripts andevalare allowed (the widget is already an isolated origin running your own code), so eval-based reactive libraries like Alpine/petite-vue/Vue work fine. - Each widget instance gets its own short-lived, scoped session, so one widget instance cannot impersonate another even on the same origin.
- The widget script runs as the plugin itself with the same
sw.*scoping as any other plugin script — there is no new trust boundary, just a new render surface.
Iframe shell behavior
Every wrapped response loads sw-widget.js, which:
- Auto-resizes the iframe to fit content via
ResizeObserver(capped to 4000px). - Listens for
refreshfrom the parent (sent when the admin clicks the hover-toolbar↻or the page-header refresh button) and reloads the iframe. The widget does not need to opt in. - Listens for
config-changedfrom the parent. By default, a config change triggers a full iframe reload (the new config is picked up on the nextfetch(ctx)). To handle config changes without a reload, definewindow.__widget_reload = function(newConfig) { ... }in your template.
Custom Liquid Filters
Plugins can introduce new Liquid filters by exporting functions with the filter. prefix.
module.exports = {
"filter.reading_time": function (value) {
const text = String(value || "");
const words = text.split(/\s+/).filter((w) => w.length > 0).length;
return Math.ceil(words / 200) + " min read";
}
};
Usage in theme: {{ article.content | reading_time }}
Bridges (APIs)
Plugins have restricted access to system resources via the sw global object and global functions.
Fetch API
HTTP requests use a standard, browser-like fetch. The response is lazy — the body
isn't read until you consume it:
const res = fetch("https://api.example.com/data");
res.status; res.ok; res.headers; // metadata
const data = res.json(); // or res.text() / res.bytes() — buffers (capped at 1 MB)
Every readable (fetch, gdrive.download, files.download) shares the same shape:
.body— a streaming handle; pipe it tosw.files.upload/sw.csv.readerwith no buffering and no size cap..text()/.json()/.bytes()— buffer the whole body into memory (capped at 1 MB for fetch; stream via.bodyfor anything larger). Consume-once but cached, so calling.json()after.text()is fine.
Streaming the response to storage (a large download never lands in memory):
const res = fetch("https://api.example.com/big-export.csv");
if (!res.ok) throw new Error("download failed: " + res.status);
sw.files.upload("imports/remote.csv", res.body); // download → storage
// ...or decode row-by-row: sw.csv.reader(res.body, { header: true })
Streaming a file as the request body. body accepts a string or a .body stream
(from sw.files.download(path).body, or another response's .body). With a stream the
file goes straight to the request without ever loading into memory:
fetch("https://api.example.com/import", {
method: "POST",
headers: { "Content-Type": "text/csv" }, // defaults to application/octet-stream
body: sw.files.download("exports/products.csv").body
});
(Note: {secret.*} is not expanded inside a streamed body — only string bodies under
16 KB get secret expansion. Connection + response-header timeouts are bounded by the
client; the body read/stream is bounded by the script/task budget.)
SSRF guard.
fetchcan only reach public addresses. Connections to loopback, private (RFC1918 / ULA), link-local (incl. the169.254.169.254cloud-metadata endpoint), and multicast IPs are refused — re-checked at connect time (so DNS rebinding can't sneak past) and on every redirect hop, and redirects to non-http(s)schemes are rejected. (Local development is exempt so you can calllocalhostservices.)
Form posts. As in the browser, the body's type picks the encoding:
FormData→multipart/form-data(always, even with no files).append(name, value, filename?)takes a string (a field) or a.bodystream / bytes (a file). File parts are streamed (never buffered), theContent-Type+ boundary is set for you, and each part's type is guessed from its filename. Reader parts close automatically.const fd = new FormData(); fd.append("title", "Q1 export"); fd.append("file", sw.files.download("exports/products.csv").body, "products.csv"); fetch("https://api.example.com/upload", { method: "POST", body: fd });URLSearchParams→application/x-www-form-urlencoded. Standard accessors (append/set/get/getAll/has/delete/toString); also constructs from a string or object. Use it for token endpoints and classic form posts:const p = new URLSearchParams(); p.append("grant_type", "client_credentials"); fetch("https://api.example.com/token", { method: "POST", body: p }); // also handy for query strings: url + "?" + new URLSearchParams({ q, page }).toString()
Store Bridge (sw.products, sw.orders, sw.customers, sw.records)
Manage database entities. All store bridges support both single-item and batch operations.
The field-level shape of every record these bridges return and accept — Product, Order, Customer, Coupon, and custom records, with each field's type and whether it's settable — is documented in Entities.md.
Products
// Single operations
const p = sw.products.save({ name: "Widget", price: 1000 });
const product = sw.products.get(p.id);
sw.products.delete(p.id);
// Batch operations
const batch = sw.products.get([id1, id2, id3]); // → array of products
sw.products.delete([id1, id2]); // → deleted count
// List with filters
const list = sw.products.list({ filters: { active: true }, limit: 10 });
// Canonical storefront URL for a product, using the active theme's product
// route (e.g. "/product/widget/123", or "/product/summer-sale" when the product
// has a custom slug). Accepts a product object or any
// { id | product_id, name, slug, shop_id } shape. Returns "" if there's no id.
const url = sw.products.url(product);
sw.products.url is the same resolver behind the Liquid product_url filter, so a plugin building links (sitemap entries, feeds, emails) produces URLs identical to the theme. When a product has a custom slug, the clean id-less URL is returned; cross-shop (wired) products are encoded automatically. A product object carries slug (its current custom slug, or ""); passing it through sw.products.save({ ..., slug: "summer-sale" }) sets the canonical slug and 301-redirects the old one (the slug is handleized and must be unique within the shop, else the save fails).
sw.products.search(opts) — full-text / faceted search
list queries the store directly (exact filters, see Filter operators); search runs the same search index the storefront search page uses — relevance-ranked full-text, faceted filters, and an approximate total.
const res = sw.products.search({
query: "blue shirt", // full-text query ("" → default listing)
limit: 24, // page size
cursor: prevRes.cursor, // pass the previous result's cursor to page
filters: { color: "blue", brand: "acme" }, // exact facet-field filters (string values)
facets: ["color", "brand"] // request facet counts for these fields
});
res.items; // → array of products (priced for the storefront)
res.cursor; // → cursor for the next page (absent on the last page)
res.total_count; // → approximate total matching products (for "X results" / page counts)
res.facets; // → { color: [{ value: "blue", count: 12 }, …], brand: [...] }
res.facet_labels; // → { color: "Color", … } display labels, when the field defines one
filtershere are exact equality on indexed facet fields and take string values (unlikelist's range operators). Use them to drill down within a search.facetsasks the index to return value→count buckets for those fields so you can render a faceted sidebar; omit it if you only need results.total_countis the index's approximate match count — fine for "about N results" and page counts, not for exact accounting. It is only available onsearch;listreturns{ items, cursor }with no count.
Orders
const order = sw.orders.get(orderId);
const list = sw.orders.list({ limit: 10 });
// Orders are writable too. save() merges onto the stored order (it loads by id,
// then overlays the fields you pass), so a status/tracking patch won't clobber the
// rest of the order. Persisting a status change fires order.before_save /
// order.after_save, so core's own side effects still run — e.g. setting status to
// "shipped" sends the customer the shipping email.
sw.orders.save({
id: order.id,
status: "shipped",
trackings: (order.trackings || []).concat([{ carrier: "UPS", number: "1Z…" }])
});
A fulfillment plugin (e.g. an inventory system) can drive fulfillment from its own UI: decrement its stock, then
sw.orders.save({ id, status: "shipped", trackings })to reflect it onto the storefront order. Guard against re-firing — an order can be saved many times, so make the shipment side effect idempotent (record which orders you've already fulfilled).
An order carries created_by_user_id — the staff/admin user who placed it on the
customer's behalf (the admin order builder, or a Sales Rep acting for the
customer; see Acting on behalf of customers).
It's 0 for an ordinary storefront self-checkout, and it's indexed, so a
plugin can both branch on it in a hook and query it:
// Commission a rep on the orders they placed.
module.exports["order.after_save"] = function (ctx) {
const o = ctx.data;
if (ctx.old_data || !o.created_by_user_id) return; // only new, rep-placed orders
sw.ledger.credit("rep:" + o.created_by_user_id, Math.round(o.totals.subtotal * 0.05));
};
// All orders a given rep placed.
sw.orders.list({ filters: { created_by_user_id: repUserId }, limit: 50 });
Customers
const c = sw.customers.save({ email: "[email protected]", name: "Alice" });
const customer = sw.customers.get(c.id);
sw.customers.delete(c.id);
sw.customers.startImpersonation(customerId) → { token, url } — mint a
stateless 5-minute session for the customer on the acting staff member's behalf.
Requires "impersonation": true in the manifest and an admin/widget context;
throws otherwise. url is the storefront /impersonate/start?token=… link that
logs the rep in as the customer; orders placed in that session are stamped with
order.created_by_user_id = the rep. Audited + rate-limited. See
Acting on behalf of customers for
the full flow.
Coupons
const c = sw.coupons.save({ code: "SAVE10", type: "percent", value: 1000 }); // value: basis points (10000 = 100%) for percent, cents for fixed
const coupon = sw.coupons.get(c.id); // keyed by the numeric id, NOT the code
sw.coupons.delete(c.id);
A coupon is keyed by a numeric id (like every other built-in record); the
human code lives in the code field and is unique per shop (so it can be
renamed). To resolve a coupon from a code a shopper typed, list and match on
code (sw.coupons.list({ filters: { code: "SAVE10" } })) rather than calling
get with the code.
Attaching plugin data to built-in records (.meta)
Every built-in record — orders, customers, coupons, products, and the like —
carries a free-form .meta object your plugin can write to. Use it to stamp
plugin-specific data directly onto the record it belongs to, instead of keeping a
parallel custom record keyed by the built-in's id:
// In a before-save hook, mutating ctx.data persists with that same save:
module.exports["order.before_save"] = function (ctx) {
const order = ctx.data;
order.meta = order.meta || {};
order.meta.gift_wrap = true;
order.meta.source_campaign = "spring";
// no explicit save needed — it's written as part of this save
};
// Anywhere else (route, widget, scheduled run), write it back explicitly:
const c = sw.customers.get(customerId);
c.meta = c.meta || {};
c.meta.loyalty_tier = "gold";
sw.customers.save(c); // persist the change
// Read it back later:
const order = sw.orders.get(orderId);
const wrap = order.meta && order.meta.gift_wrap;
.meta is opaque storage — it persists with the record but is not
queryable. You can read it once you have the record, but you cannot filter or
look records up by a .meta value in list(). So:
- Reach for
.metawhen the data belongs to one built-in record and you'll always load that record first anyway — a flag, a snapshot, an external id, a small bag of attributes. It travels with the record and needs no extra storage to declare. - Reach for a custom record when you need to find or list by the data (query by status, aggregate, paginate), when it's a first-class entity in its own right (a loyalty ledger row, a sync job), or when it doesn't map one-to-one onto a single built-in record. Link it back to the built-in by storing that record's id as a field.
.metamutated inside a before-save hook is written as part of that save — no extra call. Set from anywhere else (a route, widget, or scheduled run), callsw.orders.save(...)/sw.customers.save(...)etc. to persist it.
Custom Records
Custom records support full batch CRUD via array arguments:
// Single operations
const r = sw.records.my_type.save({ title: "Hello", value: 123 });
const record = sw.records.my_type.get(r.id); // returns null if no record with that id
sw.records.my_type.delete(r.id);
// Batch get — pass an array of IDs, returns array of records
const records = sw.records.my_type.get([id1, id2, id3]);
// Batch save — pass an array of objects, returns array of saved records
const saved = sw.records.my_type.save([
{ title: "First", value: 1 },
{ title: "Second", value: 2 },
{ id: existingId, title: "Updated" } // include id to update
]);
// Batch delete — pass an array of IDs, returns deleted count
const count = sw.records.my_type.delete([id1, id2, id3]);
Filter operators (list)
All store-bridge list({ filters }) calls (sw.products, sw.orders, sw.customers, sw.records.<type>) accept range filters by appending an operator to the field name. Plain keys mean equality; supported suffixes are >, >=, <, <=:
// Products priced between 100 and 500 (inclusive low, exclusive high)
sw.products.list({ filters: { active: true, "price>=": 100, "price<": 500 } });
// Reviews newer than a timestamp
sw.records.review.list({ filters: { status: "Approved", "created>": cutoff } });
You can use at most one range field per query — "price>=": 100, "price<": 500 is fine (same field), but "price>": 100, "stock<": 5 throws. If you don't pass an explicit order, the range field becomes the primary sort automatically.
Sorting (order). All store-bridge list calls (including sw.records.<type>) accept an order string naming the field to sort by; prefix it with - for descending. This sorts at the database level, so you don't have to re-sort in JavaScript:
sw.records.review.list({ order: "-created", limit: 20 }); // newest first, no filter
sw.records.review.list({ filters: { "score>=": 4 }, order: "-score" }); // sort on the filtered field
Two query constraints apply:
- One sort field —
ordernames a single property; there is no multi-key sort. (A composite index field, e.g.status_created, lets you emulate "sort by A then B" as one field — see below.) - You can't filter on one property and sort on a different one. An equality filter on
AplusorderonB(e.g.{ filters: { status: "Approved" }, order: "score" }) isn't supported directly — the query fails with "no matching index". Range filters follow the same rule: with a range filter ("price>": …) the sort must be on that same field (orderthen defaults to it, and passing a differentorderthrows). To filter by one field and sort by another, fold both into a composite index field and range-scan it (see below) — that's the supported pattern. - A sort field must be indexed. Custom-record scalar fields are indexed by default; not sortable are: fields the schema marks
index: false,json-typed fields, andrichtext/textareafields (these hold large bodies and default to unindexed — set"index": trueon the field to opt one back in). As a safety net, any string value over 1500 bytes is stored unindexed automatically so a long value never fails the save — but don't rely on this for sorting, since such a field becomes unsortable once any row exceeds the cap.
Composite custom-record indexes. When a custom-record schema declares an index: ["status", "created"]-style composite (stored as a #-joined string), pass an array value and the bridge joins it for you. Three shapes are supported:
// Exact equality on the joined composite "Approved#2026-01-01T00:00:00Z"
sw.records.review.list({ filters: { status_created: ["Approved", "2026-01-01T00:00:00Z"] } });
// Prefix match — trailing "*" on the last element opts in
sw.records.review.list({ filters: { status_created: ["Approved", "2026*"] } });
// Range — equality prefix + sortable suffix (ISO timestamp here)
sw.records.review.list({ filters: { "status_created>=": ["Approved", "2026-01-01T00:00:00Z"] } });
// Filter by A, sort by B — the "filter one field, sort another" pattern. Prefix
// the composite key with "-" to sort the matched range descending. Here: all
// "Approved" reviews, newest first (by the created suffix).
sw.records.review.list({ filters: { "-status_created": ["Approved", "*"] }, limit: 20 });
The array is collapsed to a #-joined string and compared lexically — which works correctly for sortable suffixes like ISO timestamps. Prefix matching is opt-in via *; a bare array is exact equality. A leading - on the composite key sorts the matched range descending (omit it for ascending). For a bounded composite range, use two filters on the same composite field ("status_created>=": [...] plus "status_created<": [...]).
Where you put * matters. Glued to a value ("1*") means prefix-within-the-field; as its own array element ("*") means match anything from the next # boundary onward. For an index: ["status", "user_id", "created"] composite, "all approved reviews by user 1":
// CORRECT — anchors user_id at exactly 1, then matches any created suffix
sw.records.review.list({ filters: { comp: ["Approved", 1, "*"] } });
// → range ["Approved#1#", "Approved#1#�")
// WRONG — also matches user 10, 11, 100, …
sw.records.review.list({ filters: { comp: ["Approved", "1*"] } });
// → range ["Approved#1", "Approved#1�")
The # separator is what creates the field boundary; place * as its own element when you want to anchor on the field boundary, glue it to the value when you genuinely want a mid-field prefix (e.g. matching slugs that start with "hello-").
Storage Bridge (sw.storage)
Durable key/value store, scoped to the plugin and the current shop. Use bare
keys — the bridge namespaces by plugin id for you (no "plugin-name:" prefix).
sw.storage.set("config", { enabled: true });
const cfg = sw.storage.get("config");
sw.storage.delete("config");
const page = sw.storage.list({ prefix: "order:", limit: 50 }); // { items:[{key,value}], cursor? }
Storage is per shop — a plugin can't reach across shops. The one supported
cross-shop need (routing an app-level payment webhook to the shop that connected a
gateway) is handled by the purpose-built sw.payments helpers below, where the
platform owns the storage format. There is no general cross-shop key/value bridge.
Linking connected accounts (sw.payments)
Connected-account gateways (e.g. Square) deliver every seller's events to one
global URL with a provider account id (merchant_id) in the payload — there's no
shop_id in the URL. sw.payments.linkAccount(accountId) records that the
account belongs to the current shop in a platform-owned, cross-shop account→
shop index (restricted to marketplace plugins). The platform reads it back to
resolve the shop before running payment.webhook, so your hook never resolves the
shop itself.
An account id can only be linked to one shop:
linkAccountthrows if the account is already linked to a different shop (re-linking by the owning shop is a no-op), andunlinkAccountonly removes a mapping the current shop owns. This stops one shop from repointing another's connected-account webhooks.
Serving one provider account from multiple shops. Because an account id can
only point to one shop, link a more specific, colon-separated id when the same
provider account (e.g. one Square merchant) backs several shops — one per
sub-scope such as a Square location: merchant_id + ":" + location_id. The
platform resolves hierarchically (full id first, then strips trailing :segments),
so a bare-merchant_id link and merchant-level events with no sub-scope still
route via the prefix. The link site (OAuth callback) can read the configured
sub-scope from ctx.settings, but payment.webhook_account runs before the shop
is known — it must pull the sub-scope from the event body and produce the same
key. Free the mapping in a plugin.uninstall handler (unlinkAccount(sameKey))
so the account can be reclaimed; leave it across plugin.deactivate (reversible,
and a stale mapping is inert while the plugin is inactive).
// OAuth callback (runs in the connecting shop's context); ctx.settings is this
// shop's, so key by merchant + the configured location when present:
const loc = (ctx.settings.location_id || "").trim();
sw.payments.linkAccount(loc ? merchant_id + ":" + loc : merchant_id);
// sw.payments.unlinkAccount(...) on disconnect / in plugin.uninstall.
// payment.webhook_account — bridgeless parser, no sw.* and no per-shop settings;
// derive the SAME key from the body (location lives on the event object):
const ev = JSON.parse(ctx.data.body || "{}");
const obj = (ev.data && ev.data.object) || {};
const loc = (obj.payment && obj.payment.location_id) || obj.location_id || "";
ctx.data.account_id = ev.merchant_id ? (loc ? ev.merchant_id + ":" + loc : ev.merchant_id) : "";
// payment.webhook — now namespaced to the resolved shop; report the refund and
// the platform resolves the order from refund_id (stored on the order):
ctx.data.refund_id = refund.id;
ctx.data.refund_status = "succeeded";
sw.payments.settleRefund(providerRefundId, status) finalizes an async refund
from a non-webhook context — e.g. a scheduled job that polls the provider, or
a manual "reconcile" action — where there's no ctx.data event to report on.
status is "succeeded" or "failed". It runs the same path as the refund
webhook: the platform resolves the order from the refund id (stored on the order),
flips the matching refund, recalcs order status, sends the email, restocks, and
reverses any wired-supplier ledger. So you trigger settlement explicitly without
reimplementing — or being able to bypass — those side effects. It throws if no
order carries that refund id. (Available in any shop context, not just
marketplace plugins; it only touches the current shop.)
// e.g. inside a scheduled "run" hook that polls Square for pending refunds:
const r = fetch(baseUrl + '/v2/refunds/' + refundId, { headers: { Authorization: 'Bearer {secret.access_token}' } }).json();
if (r.refund && r.refund.status === 'COMPLETED') {
sw.payments.settleRefund(refundId, 'succeeded');
} else if (r.refund && (r.refund.status === 'FAILED' || r.refund.status === 'REJECTED')) {
sw.payments.settleRefund(refundId, 'failed');
}
Secrets Bridge (sw.secrets)
Encrypted key/value store for credentials (API keys, OAuth tokens, signing
secrets), scoped to the plugin and the current shop. Unlike sw.storage,
values are encrypted at rest and are write-only by default — a stored secret
cannot be read back into the script. Instead, reference it as {secret.KEY} in
fetch headers/body or crypto.createHmac(...), where it is expanded at the
HTTP/crypto boundary and never materialises as plaintext.
Declaring secrets in the manifest
Add a top-level secrets array to manifest.json to hint the Secrets panel:
each declared key shows up as a labeled entry the merchant can click to fill in, so
they know exactly which credentials your plugin expects without reading the code.
It's a UI hint only — values are still stored and consumed through sw.secrets /
{secret.KEY} as below.
"secrets": [
{ "key": "STRIPE_SECRET_KEY", "help": "Your Stripe secret key (sk_live_…)." },
{ "key": "STRIPE_PUBLISHABLE_KEY", "help": "Publishable key for the client SDK.", "readable": true }
]
key: (Required) The secret name, referenced at runtime as{secret.KEY}or viasw.secrets.get.help: (Optional) Guidance shown as the field's tooltip in the panel.readable: (Optional) Pre-fills the entry as readable (sosw.secrets.getcan return it). Leave it off for anything sensitive — keep signing secrets and private keys write-only and use{secret.KEY}expansion instead.
sw.secrets.set("STRIPE_SECRET_KEY", "sk_live_..."); // write-only (default)
sw.secrets.has("STRIPE_SECRET_KEY"); // → true/false
sw.secrets.delete("STRIPE_SECRET_KEY");
// Use a write-only secret without reading it — expanded at the boundary:
fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: { Authorization: "Bearer {secret.STRIPE_SECRET_KEY}" }
});
Readable secrets
Pass true as the optional third argument to set to mark a secret readable,
so sw.secrets.get(key) returns its plaintext value. Use this only for values the
script genuinely needs in hand — e.g. a Stripe publishable key that must be
injected into a template binding for the client SDK:
sw.secrets.set("MY_PUBLISHABLE_KEY", "pk_live_...", true); // readable
const key = sw.secrets.get("MY_PUBLISHABLE_KEY"); // → "pk_live_..."
get returns "" for a missing secret or one stored write-only. Prefer the
default (write-only) for anything sensitive: a signing secret or private API key
should be expanded via {secret.KEY} rather than marked readable. See
Configuring payment webhooks — a webhook signing
secret does not need readable because crypto.createHmac expands the
placeholder for you.
JWT Bridge (sw.jwt)
Mint and verify your own stateless, expiring tokens (JSON Web Tokens) — e.g. a
short-lived download link, a signed callback nonce, or a token an external service
will verify. Symmetric (HS256/HS384/HS512, shared secret) and asymmetric
(RS*, ES*, EdDSA — sign with a PEM private key, verify with the public key)
algorithms are supported. The secret/key flows through {secret.KEY} expansion,
so the material never appears in script scope.
// HS256 (default): sign with a secret, verify with the same secret.
const token = sw.jwt.sign(
{ sub: customerId, scope: "download" },
"{secret.SIGNING_KEY}",
{ expiresIn: 900 } // seconds → stamps `exp`; `iat` is always set
);
const claims = sw.jwt.verify(token, "{secret.SIGNING_KEY}"); // throws if bad sig or expired
// claims.sub, claims.scope, claims.exp, claims.iat
// Asymmetric: sign with a private key, hand the public key to a third party.
const t = sw.jwt.sign({ iss: "my-plugin" }, "{secret.EC_PRIVATE_KEY}",
{ algorithm: "ES256", expiresIn: 3600 });
const c = sw.jwt.verify(incomingToken, "{secret.GOOGLE_PUBLIC_KEY}", { algorithm: "RS256" });
// Inspect an untrusted token WITHOUT verifying (e.g. read its `kid` to pick a key).
const { header, payload } = sw.jwt.decode(incomingToken); // strings; do NOT trust for authz
sign(claims, key, opts?)→ token string.opts.algorithm(default"HS256"),opts.expiresIn(seconds; setsexp).iatis stamped automatically. For a fixed expiry setclaims.expyourself and omitexpiresIn.verify(token, key, opts?)→ claims object; throws on a bad signature, an expired/not-yet-valid token, or an algorithm mismatch.opts.algorithm(default"HS256") pins the accepted algorithm — the token'salgheader must match, and the key is parsed only for that algorithm. This is the algorithm-confusion guard: always pin the algorithm you expect, especially for tokens minted elsewhere.decode(token)→{ header, payload }(raw JSON strings), no signature or expiry check. Use only to peek at an untrusted token; never authorize on it.
alg: "none"is rejected at sign and verify. There is no way to produce or accept an unsigned token through this bridge.
Cache Bridge (sw.cache)
Short-term key-value store. Keys are automatically scoped to your (shop, plugin) —
use bare keys (no shop/plugin prefix); another shop or another plugin can't read,
overwrite, or evict your entries, and rateLimit counters are isolated too.
sw.cache.set("my_key", { data: 123 }, 60); // TTL in seconds
const data = sw.cache.get("my_key");
sw.cache.delete("my_key");
// Rate limit by key — returns { allowed, remaining, reset_at }
const rl = sw.cache.rateLimit("user:" + userId, 100, 60); // 100 hits / 60s window
if (!rl.allowed) {
// over the limit; rl.reset_at is a unix timestamp (seconds)
}
sw.cache.rateLimit(key, limit, windowSeconds) is a fixed-window counter you
call to throttle anything a plugin can spam — a per-user action, an outbound API
call, a webhook fan-out. Each call counts as one hit against key within the
current windowSeconds window and returns:
| Field | Meaning |
|---|---|
allowed | false once more than limit hits land in the window — gate the action on this. |
remaining | Hits left in the current window (0 when blocked). |
reset_at | Unix timestamp (seconds) when the window resets and the count clears. |
Notes:
- Keys are plugin- and shop-scoped automatically (the bridge prefixes them), so
use a bare, meaningful key like
"sms:" + userId— no plugin-name prefix. - It fails open: if the cache backend is unreachable the call returns
allowed: true, so a cache outage never hard-blocks your plugin (it also can't enforce the limit during the outage — don't rely on it as a security control). - The window is fixed, not sliding: all hits in the same
windowSecondsbucket share onereset_at.
const rl = sw.cache.rateLimit("export:" + shopUserId, 5, 3600); // 5 exports/hour
if (!rl.allowed) {
throw new Error("Export limit reached. Try again after " +
new Date(rl.reset_at * 1000).toLocaleTimeString());
}
// …proceed with the export
Platform bridge rate limit (automatic)
Separately from the sw.cache.rateLimit helper you call yourself, the platform
automatically meters costly sw.* calls so a runaway loop can't hammer the
platform. You don't opt in — it's always on. Each costly call spends
weighted units, and the weights mirror the real cost of each operation (a write costs
~3× a read; a delete less than a read):
| Class | Calls | Cost |
|---|---|---|
| Write | *.save, storage.set, ledger.credit/debit/compact, files.upload | 3 units × item count |
| Delete | *.delete, storage.delete, files.delete | 1 unit × item count |
| Read | *.get (× item count); files.read/download, storage.get | 1 unit |
| Query | *.list, *.getBySlug, products.search, ledger.balance/history/list/sum, files.list | 5 units |
| Free | sw.cache, crypto, sw.jwt, sw.csv, sw.sql (your own DB), fetch, sw.secrets, sw.notify, sw.bus, sw.task, sw.email, … | 0 units |
This covers the built-in record namespaces (products, orders, customers,
coupons, records, storage, ledger, files, wiredProducts) and your
custom record types. Batchable ops (save/delete/get) cost per item,
because a save([...]) of 500 rows really is 500 writes — batching
saves the round-trips, not the per-entity cost.
How the limit works — two layers:
Per-second throttle. The platform throttles your shop's concurrent runs at a per-second rate with a burst allowance. Set generously so normal burst work never trips it — it's an abnormal-activity trip-wire, not a quota. It adds no latency to your calls; it stops a runaway loop within the current run.
Plan Burst Sustained (units/sec) Free 3,000 200 Pro 8,000 500 Business 20,000 1,000 Enterprise 30,000 2,000 Sustained-abuse block (platform-wide). A trip blocks the offending script's next run (before it starts), starting at about a minute and doubling if it keeps tripping (capped at 1h). The block lifts automatically when it expires. It's keyed to the script's code, so it stops that script everywhere without touching your shop's other plugins.
When you're over budget the call throws bridge rate limit exceeded: too many costly sw.* calls; slow down or batch your operations — a normal,
catchable error (it also surfaces in the admin log viewer). Key points:
- Batch, and spread bulk work out. Prefer one
save([...])over a per-row loop (one round-trip, less CPU). For large imports, page across requests (orsw.task.continue()) rather than draining your whole burst at once. - Reads are cheap, writes are dear, queries cost a flat 5 — mirror that in
your loops; cache hot reads via
sw.cache(free). - Trip it and your script gets benched until the block expires — so fix the loop, don't just retry.
// ❌ N round-trips, 3 units each → 500 rows = 1,500 units, 500 RPCs
for (const r of rows) sw.records.save("invoice", r);
// ✅ one round-trip, still 3 units/row (it's 500 real writes) but far less CPU
sw.records.save("invoice", rows);
Recipe — importing a large CSV without tripping the limit. Splitting the
write work across requests (each sw.task.bg closure gets its own fresh burst
budget) is the way to bulk-import past the per-run cap. Full walkthrough in
Recipes.md — Importing a large CSV.
Memory & timeout limits
Scripts run with bounded memory and a per-kind time budget, so a few habits keep your plugin fast and within limits:
- Don't hold a large heap for the whole run. Finishing a run while still
holding a large live heap (e.g. you built a 300 MB array and kept a reference)
risks running out of memory — and it happens wherever the allocation lives,
including inside a
require()d module. The fix is almost always to stream or page instead of materializing everything: prefersw.files.upload(path, sink => …)andsw.task.continue()over building the whole result in memory. - Avoid hot allocation loops. Sustained high churn-per-second is wasteful; background tasks get much more headroom than request-path hooks, since streaming syncs churn fast by design.
- Stay within the time budget. A hook or cart/checkout path gets ~5s; a fetch
route / widget / "Run" test gets ~30s (see Timeout Awareness).
A run that exceeds its budget is interrupted. For anything heavy — an unbounded or
CPU-bound loop, a big synchronous transform, or a sequential fan-out of blocking
external calls — move the work to a background task (
sw.task.bg) and stream/serve the result viasw.files.
Practical takeaway: stream large outputs instead of buffering them, and move heavy
work off the request path into sw.task.bg.
Files Bridge (sw.files)
Upload and manage files.
const txt = sw.files.upload("demo/hello.txt", "Hello World", "text/plain");
const img = sw.files.upload("demo/pic.jpg", fetch("https://example.com/pic.jpg"));
const list = sw.files.list("demo/");
sw.files.delete(txt.path);
Streaming uploads — sw.files.upload(path, sink => {...}, contentType?). Passing
a string/bytes builds the whole file in memory first. Pass a callback instead and
upload hands you a sink that streams each chunk straight to storage — memory
stays bounded to one chunk no matter how large the file:
const res = sw.files.upload("exports/report.csv", (sink) => {
sink.write("id,name\n"); // each write() is copied straight to the destination
sink.write("1,Acme\n");
}, "text/csv"); // -> { path, url, public_url?, size }
sink.write(data)accepts a string or bytes and returns the number of bytes written.- Lifecycle is scoped to the callback — there's nothing to close. The upload
finalizes when the callback returns, and aborts (the whole
uploadthrows) if the callback throws, so a failure never persists a partial file as a success. - Pair it with
sw.csv.writerto stream row batches (see CSV bridge below).
Reading files. sw.files.read(path) returns the whole file as a string — the
quick "read my config" call. For streaming, JSON, or large files, sw.files.download(path)
returns the same lazy readable as fetch — { body, text(), json(), bytes() }:
const cfg = sw.files.read("config.txt"); // string (the common case)
const data = sw.files.download("data.json").json(); // parse without JSON.parse(read())
sw.csv.reader(sw.files.download("big.csv").body, { header: true }); // stream row-by-row
sw.files.upload("copy.csv", sw.files.download("orig.csv").body); // copy, never buffered
.body is a stream handle: consuming it through a cursor/upload releases it on drain, or
call .body.close() to release early.
Serving large files (>32 MB) — sw.files.signedUrl(path, opts?). A streamed route
response (write(out) / piping a .body) keeps memory flat but still flows every byte
through the app, so it's bound by the 32 MB response limit. To deliver a
larger file, mint a short-lived URL that downloads it directly (in production it
302-redirects to storage, so a multi-GB file never touches the app) and redirect the
browser to it from your route:
// in a route handler — gate access however you need, THEN issue the link
const url = sw.files.signedUrl("exports/big-report.csv", {
filename: "report.csv", // optional: forces a download with this name; inline if omitted
ttl: 300, // optional: seconds the link stays valid (default 900, max 3600)
});
return { status: 302, headers: { Location: url } };
- The URL is time-limited and shop-scoped — it grants read of exactly that one file until it expires. Treat it like a bearer token: only hand it out after your own access check. You can also embed it in a link or email instead of redirecting.
pathis resolved against your shop's files exactly likeupload/read, so you can only sign your own files.- In local dev (no cloud storage) the link streams the bytes back through the app instead of redirecting — same URL, same behavior, just no size advantage locally.
Task Bridge (sw.task)
Execute heavy operations as parallel background tasks. Each runs as its own isolated run, letting you use all sw bridges concurrently without blocking the main script.
// Closures lose outer scope, so pass external variables as arguments.
const t1 = sw.task.run((url) => fetch(url).json(),
"https://api.example.com/data1");
const t2 = sw.task.run(() =>
sw.sql.connect("turso", "{secret.DB_DSN}").query("SELECT * FROM users").all());
// Wait for all background tasks to complete
const [fetchResult, sqlResult] = sw.task.join(t1, t2);
Durable Background Tasks (sw.task.bg)
sw.task.run runs inline and only lives as long as the current request or script. For durable, fire-and-forget work that must outlive the request — and that should respect a per-plan concurrency limit — use sw.task.bg.
// Fire-and-forget: the closure runs later, as a separate task.
// Returns an opaque task id (string); it cannot be join()-ed.
// Signature: sw.task.bg(fn, opts?) — opts is { delay?, args? }.
sw.task.bg((ctx, productId) => {
const p = sw.products.get(productId);
// ... slow work: call an external API, regenerate a thumbnail, etc.
}, { args: [product.id] });
- Durable: the task is persisted before it runs, so it executes even after the triggering request finishes, and survives restarts — it is not lost when the current run ends.
- Fire-and-forget: returns a task id string and cannot be
join()-ed (it runs in a different process). When you need the result inline, usesw.task.run+sw.task.joininstead. - Per-plan concurrency: each shop runs at most a tier-based number of background tasks at once — Pro: 1, Business: 5, Enterprise: 20. Tasks beyond the cap are queued and start automatically as running ones finish.
- Closures lose outer scope: the closure captures nothing from the surrounding scope — pass any external values it needs via
opts.args(forwarded afterctx), exactly likesw.task.run. Most closures insteadrequire()their dependencies inside. - Full bridge access: each task runs as its own isolated run with all
swbridges, and may itself enqueue moresw.task.bgwork. - 10-minute limit + continuation: each task is subject to the standard 10-minute execution limit. For longer work, a closure can call
sw.task.continue(data)to resume in a fresh task (see Task Continuation below) — it keeps its concurrency slot across the whole chain. (Dedicated tier: the per-run limit is configurable — it tracks the shop's container idle window,idle − 5m, so a shop set to a 20-minute idle window allows 15-minute tasks. Usectx.timeoutRemaining()rather than assuming 10 minutes.) - Options object (
opts, the optional second argument — consistent withsw.task.continue(data, opts)):args: array forwarded to the closure as positional arguments afterctx.delay: milliseconds to wait before running (capped at 1 hour). A delayed task waits as a pending task holding no concurrency slot until its run time — so a polling job doesn't pin the shop's slot while it waits. Example:sw.task.bg(fn, { delay: 30000, args: [productId] }).
- Anti-runaway limits: to prevent fork-bomb mistakes (a task that endlessly enqueues a successor),
sw.task.bgthrows if a chain of tasks spawning tasks exceeds 100 generations, or if a shop enqueues more than its per-minute budget (Pro: 120, Business: 600, Enterprise: 2400). For long jobs prefersw.task.continue()(which keeps one slot) over recursivesw.task.bg. The error appears in the admin log viewer. - Errors thrown inside the closure are recorded in the admin log viewer.
A bg closure receives a ctx object as its first argument followed by opts.args. The ctx mirrors what named scripts see: ctx.continue.data is the payload from the previous sw.task.continue() call (the continue field is undefined on the first run), ctx.continue.depth is the continuation generation, and ctx.timeoutRemaining() returns the milliseconds left before the task is killed.
Background tasks (both sw.task.bg closures and scheduled scripts) also receive ctx.shop — the same curated shop object exposed everywhere a shop is bound (storefront route ctx.shop, widget ctx.widget.shop, and the shop key in hook data such as cart.calculate_prices / payment.*). Its fields: id, name, slogan, subdomain, domains, currency, payment_provider, canonical_host, canonical_url, plus nested theme and auth objects. name/slogan are the merchant's storefront name and tagline and are optional (empty when unset) — treat them as hints (e.g. to ground generated content), not guarantees. ctx.shop_id remains available as the bare id.
The exposed set is identical to the storefront
{{ shop.* }}object (see Themes.md) — it's the one allowlisted projection of the shop, so a raw shop is never leaked to plugin or template.
// A self-continuing closure that pages through a large data set.
sw.task.bg((ctx) => {
let cursor = ctx.continue?.data?.cursor || "";
while (true) {
const page = sw.products.list({ cursor, limit: 100 });
// ... process page.items ...
if (!page.cursor) break; // done
cursor = page.cursor;
if (ctx.timeoutRemaining() < 30000) {
sw.task.continue({ cursor }); // resume in a new task; halts here
}
}
});
A common pattern is to fan work out from a hook without blocking the save:
module.exports = {
"order.after_save": function (ctx) {
const orderId = ctx.data.id;
// Return immediately; the heavy work runs in the background,
// throttled to the shop's plan concurrency.
sw.task.bg((ctx, orderId) => {
const order = sw.orders.get(orderId);
fetch("https://erp.example.com/sync", {
method: "POST",
body: JSON.stringify(order)
});
}, { args: [orderId] });
}
};
Recovering failed / abandoned orders
Core never sweeps or deletes orders whose payment didn't go through (a decline
stays payment_failed, an abandoned checkout stays created) — they're left in
place on purpose so a plugin can run retry nudges, win-back campaigns, or its own
cleanup, either reactively from order.after_save or from a scheduled run
sweep. Walkthrough in
Recipes.md — Recovering failed / abandoned orders.
Task Continuation (sw.task.continue)
Any background task — a named script (cron job / "Run Background") or an sw.task.bg closure — is subject to a 10-minute execution limit. For long-running work like syncing thousands of products, call sw.task.continue() to re-enqueue the current task as a fresh one and immediately halt the current run. The task keeps its concurrency slot for the entire chain, and because its checkpoint is stored durably, a crashed continuation resumes from the last checkpoint rather than restarting.
sw.task.continue(); // no data, resume immediately
sw.task.continue({ cursor: "abc" }); // pass ephemeral data to the next run
sw.task.continue({ jobId: "j1" }, { delay: 30000 }); // resume in ~30s
- Ephemeral data: Pass an optional object to
continue(). The next run reads it viactx.continue.data— same shape for both named scripts andsw.task.bgclosures. - Optional delay: pass
{ delay: ms }as the second argument (milliseconds, capped at 1 hour) to resume later instead of immediately. Slot semantics differ by mode: an immediate continuation keeps its concurrency slot across the chain (lowest latency); a delayed continuation releases its slot while it waits and re-acquires one when it resumes — so a poll loop doesn't pin the shop's only slot between checks. Use a delayed continuation to poll an external job instead ofsleep-ing to keep the run alive (which burns the 10-minute budget and a slot doing nothing). - Continuation depth: tracked as
ctx.continue.depth, starting at0. Maximum of 100 continuations to prevent infinite loops. - Lock preservation (named scripts): the background run lock (
run_bg_lock) is refreshed on each immediate continuation, preventing cron from spawning duplicate runs of the same script. - Immediate halt: After
continue()is called, execution stops immediately — code after the call is never reached.
⚠️ A continuation chain runs frozen code — deploys don't reach it. The source of an
sw.task.bgclosure is captured (as text) the moment you enqueue it and stored on the durable task record.sw.task.continue()re-runs that same stored source — it does not re-read your plugin. So a chain that started before you shipped a new version keeps running the old closure body for its entire life, no matter how many times you redeploy. (Inlining code into the closure doesn't help — the inlined text is exactly what's frozen.) Only the closure body itself is frozen:require()'d modules are re-resolved against the currently deployed version on every run, including continuations — so the standard escape hatch is to keep the closure a thin shell and put the real logic in arequire()'d file, where a redeploy lands even into an already-running chain. A new chain started after the deploy picks up everything normally.To make a long chain deploy-aware, stamp the running version into the checkpoint and self-terminate when it changes so a fresh task re-captures current source:
sw.task.bg((ctx) => { const VERSION = "3.7.54"; // bump in lockstep with manifest.json // A chain started on an older build carries the old VERSION in its frozen // source; this one started on the new build. If a checkpoint from an older // chain reaches us, stop so the operator can start a clean run. if (ctx.continue?.data && ctx.continue.data.v !== VERSION) { console.log(`stale chain v${ctx.continue.data.v} != v${VERSION}; stopping`); return; // let the operator kick off a fresh sync on the new code } let cursor = ctx.continue?.data?.cursor || ""; // ... process one batch ... if (moreWork) sw.task.continue({ cursor, v: VERSION }); });If a chain is already wedged on old code, deleting its pending background task (admin → background tasks) and re-triggering the job is the immediate fix.
Polling an external job without sleep — each check is a fresh, short run; the task holds no slot between checks:
sw.task.bg((ctx) => {
const jobId = ctx.continue?.data?.jobId || sw.storage.get("pending_job");
const status = fetch("https://api.example.com/jobs/" + jobId).json().status;
if (status === "done") {
// ... handle completion ...
return; // task ends
}
sw.task.continue({ jobId }, { delay: 15000 }); // re-check in ~15s; halts here
});
Full example:
module.exports.run = function (ctx) {
let cursor = ctx.continue?.data?.cursor || "";
console.log("Run #" + (ctx.continue ? ctx.continue.depth : 0));
while (true) {
const result = sw.products.list({ cursor, limit: 100 });
for (const item of result.items) {
// ... process product ...
}
if (!result.cursor) break; // all done
cursor = result.cursor;
// Re-enqueue if running low on time (<30s remaining)
if (ctx.timeoutRemaining() < 30000) {
sw.task.continue({ cursor });
// execution stops here
}
}
console.log("All products processed!");
};
SQL Bridge (sw.sql)
Connect to your own external database — currently MySQL and Turso/libSQL (remote-only; no local/SQLite). Connections are managed per shop and reused across runs; you do not open or close them — the platform reuses and retires them automatically.
// connect-or-reuse — returns a db handle. DSN supports {secret.NAME} expansion.
const db = sw.sql.connect("turso", "{secret.DB_DSN}"); // or "mysql"
// query() returns a cursor. Rows are fetched in batches (default 200,
// override with { batch }); next()/get()/all() iterate lazily, pulling the
// next batch only when needed — efficient for large result sets.
const cur = db.query("SELECT id, name FROM users WHERE id > ?", [100], { batch: 500 });
let row;
while ((row = cur.next())) { // next() returns the next row, or null when done
console.log(row.id, row.name);
}
const user = db.query("SELECT * FROM users WHERE id = ?", [1]).get(); // single row or null
const all = db.query("SELECT * FROM users").all(); // materialize to an array
// exec() for writes — returns { rowsAffected, lastInsertId }
const res = db.exec("UPDATE users SET name = ? WHERE id = ?", ["Jo", 1]);
// batch() runs several statements atomically in one round trip (great for Turso)
db.batch([
{ sql: "INSERT INTO logs (msg) VALUES (?)", args: ["a"] },
{ sql: "INSERT INTO logs (msg) VALUES (?)", args: ["b"] },
]);
// transact() — commits on clean return, rolls back if the callback throws
db.transact(tx => {
tx.exec("INSERT INTO orders (total) VALUES (?)", [500]);
const o = tx.query("SELECT last_insert_rowid() AS id").get();
tx.exec("INSERT INTO line_items (order_id) VALUES (?)", [o.id]);
});
Reads and writes are both allowed (it's your database). A cursor holds a connection until drained;
next()/get()/all()release it automatically, but callcur.close()if you stop iterating early.
Ledger Bridge (sw.ledger)
A generic, plugin- and shop-scoped counter / double-entry primitive — use it for loyalty points, store credit / wallets, inventory counts, supplier balances, anything that's a running total with an audit trail. Each account holds a signed integer balance; what the quantity means is up to you (points, stock units, cents). credit increments, debit decrements, and every mutation runs in a transaction, so concurrent writers retry instead of losing updates.
An account is addressed by (book, ...path): the first argument is the book, the remaining strings are the account path, the lone number is the amount (a positive integer — the sign comes from the verb), and an optional trailing object is the options.
// credit(book, ...path, amount, opts?) / debit(...) → { balance, entry_id?, duplicate }
sw.ledger.credit("loyalty", "cust_1", 150, { ref: "order_1001", description: "Earned" });
sw.ledger.debit ("loyalty", "cust_1", 40, { allowNegative: false }); // overdraft → throws
sw.ledger.balance("loyalty", "cust_1"); // → 110 (0 if absent)
// Multi-segment paths model dimensions (location / counter, customer / currency, …)
sw.ledger.credit("inventory", "loc_1", "physical_stock", 10);
Options (trailing object on credit/debit): ref (indexed reference such as an order id), description, idemKey (dedupe — applying the same key twice is a no-op that returns duplicate: true, ideal for retried webhooks), allowNegative (permit the balance to go below zero), noLog (skip the audit entry — a pure counter).
// transact(fn) — multiple ops, all-or-nothing. A throw rolls everything back.
// Use the tx object inside (not sw.ledger). Other side-effectful bridges
// (fetch, sw.sql, sw.email, sw.files, …) are BLOCKED in here: the closure can
// be retried on contention, and their writes are not part of this transaction.
sw.ledger.transact(tx => {
tx.debit ("inventory", "loc_1", "physical_stock", 4);
tx.credit("inventory", "loc_2", "physical_stock", 4);
if (tx.balance("inventory", "loc_1", "physical_stock") < 0)
throw new Error("insufficient stock"); // rolls back both legs
});
// history(book, ...path, opts?) → { items, cursor? } (newest first; opts: { limit, cursor })
const h = sw.ledger.history("loyalty", "cust_1", { limit: 50 });
// Roll-ups across accounts in a book:
sw.ledger.list("inventory", "loc_1"); // { items:[{book,path,balance,...}], cursor? }
sw.ledger.sum ("inventory", "loc_1"); // sum of a path prefix (drill-down)
sw.ledger.sum ("inventory", { dimension: "physical_stock" }); // sum across one dimension
Compaction. The account holds the authoritative balance, so entries are only an audit trail. Prune them to keep storage bounded — the balance is never touched:
sw.ledger.compact("points", "cust_9", { keep: 3 }); // → { removed }
// keeps the newest 3 entries + one "snapshot" entry carrying the balance forward
// Or auto-compact: configure a book once; an account self-prunes to `keep` once
// its entry count reaches `maxEntries` (maxEntries must be greater than keep).
sw.ledger.config("points", { keep: 50, maxEntries: 200 });
Amounts are integers only (use minor units like cents for money). A
transactmay touch at most 25 distinct accounts. For a live aggregate over a huge account set (e.g. total points liability), maintain a roll-up account updated in the sametransactrather thansum-ming millions of accounts. Seecli/plugins/demo/ledger.jsfor a runnable sample.
Ledger data is isolated by plugin trust tier. Accounts are scoped to your plugin and to whether it is a genuine, unmodified marketplace install. A locally side-loaded plugin, a plugin with file overrides, or a manually-uploaded zip gets a separate, empty ledger namespace — even if it shares the same plugin id as a previously-installed marketplace plugin. So uninstalling a marketplace plugin and re-installing a local build under the same id will not expose or let you mutate the marketplace install's balances; re-installing the genuine marketplace plugin restores access to them. This mirrors how platform secrets are gated, and means dev/local iteration starts from a clean ledger.
CSV Bridge (sw.csv)
Two symmetric verbs — reader and writer. Each takes either an in-memory value or
a stream, so the same call handles a small blob or a file too large to fit in memory.
header option (both verbs): true → rows are objects; ["a","b"] → objects with
that explicit column order/names; omitted/false → rows are string arrays.
sw.csv.reader(source, opts?) → cursor { next(), all(), close() }. source is a
string or a .body stream (from sw.files.download(path).body, fetch(url).body, …).
Either way it decodes lazily (only opts.batch records at a time, default 200
— same cursor as sw.sql.query). A stream source is released automatically on drain;
call close() only to bail out early.
// In-memory string -> array of objects:
const rows = sw.csv.reader("id,name\n1,Alice\n2,Bob", { header: true }).all();
// Huge file -> stream row-by-row, never fully loaded:
const cur = sw.csv.reader(sw.files.download("exports/products.csv").body,
{ header: true, batch: 500 });
let row;
while ((row = cur.next()) !== null) { /* one row; bounded memory whatever the size */ }
sw.csv.writer(sink?, opts?) → { write(rowOrRows), toString() }. Pass a sink
(anything with a write(str) method — e.g. the sw.files.upload callback sink) and each
write() encodes its rows straight there. Pass null and it buffers, so toString()
returns the full CSV. Row type is auto-detected: objects get a header line (column order
from opts.header, else sorted keys), arrays are positional. write() takes one row
({...} or [..]) or a batch (an array of objects/arrays).
// Buffer mode (replaces the old stringify):
const w = sw.csv.writer();
w.write([{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]);
const csv = w.toString(); // "id,name\n1,Alice\n2,Bob\n"
// Streaming export end-to-end — never fully buffered. The upload sink buffers writes,
// so writing one row at a time is fine — no need to batch yourself:
const db = sw.sql.connect("turso", "{secret.DB_DSN}");
sw.files.upload("exports/products.csv", (sink) => {
const w = sw.csv.writer(sink);
const cur = db.query("SELECT * FROM products", [], { batch: 500 }); // batches the *reads*
let row;
while ((row = cur.next()) !== null) w.write(row);
}, "text/csv");
(The { batch: 500 } on the query controls how many rows are fetched at a time on the read
side; the upload sink coalesces the write side. So writing one row at a time stays cheap.)
Notify Bridge (sw.notify)
Posts entries into the shop's admin notification tray — the bell + dropdown in the admin sidebar, backed by a per-shop notifications page. Read/unread state is shop-level (shared across the shop's staff). Use this for operational alerts a merchant should see: "sync finished", "low stock", "action needed". For end-customer messages use sw.email instead — this tray is staff-facing only.
Declare your categories first. A plugin can only post notifications under a category it lists in its manifest notify_categories. Each declared category becomes its own opt-in/out row (grouped under your plugin) in every staff member's Account → Notifications — so a merchant can mute "low stock" from your plugin while keeping "sync failed". A plugin that declares none cannot call sw.notify.create.
// manifest.json
"notify_categories": [
{ "key": "sync", "label": "Inventory sync" },
{ "key": "errors", "label": "Sync errors" }
]
// Create a notification. title and category are required.
const { id } = sw.notify.create({
title: "Inventory sync finished",
category: "sync", // REQUIRED — must be one of your manifest notify_categories keys
body: "412 products updated, 3 skipped.", // optional one-liner
severity: "success", // info | success | warning | error (default "info")
link: sw.widget.url("sync-status"), // admin path to open when clicked (see sw.widget below)
email: false, // optional: false → in-app only (default: deliver per each recipient's prefs)
dedupeKey: "sync-2024-06-01" // optional idempotency (see below)
});
// List this plugin's OWN notifications (never platform or other plugins' entries).
const { notifications, cursor } = sw.notify.list({ limit: 20 /*, cursor */ });
// Dismiss one of this plugin's own notifications (no-op if it isn't yours or doesn't exist).
sw.notify.dismiss(id); // id as a number, or a string for large ids (precision-safe)
link— where clicking the notification lands the merchant. A relative admin path. Prefer the helpers that build paths for you —sw.widget.url(widgetId)for your own widget pages andsw.records.<type>.url(id)for custom records (both below). For a built-in entity, every core record has a stable detail page keyed by its numeric id:/orders/{id},/products/{id},/customers/{id},/coupons/{id}, and/fulfillments/{id}(incoming wired fulfillments). Coupons carry both a numericidand a humancode— if you only have the code, link to/coupons?q={code}(the list, pre-filtered) instead. A bare list path like/couponsis fine when you don't have a specific record.- Category is manifest-bound.
categorymust be one of your declarednotify_categorieskeys; a blank or undeclared category throws. The stored category is namespaced toplugin:<yourId>:<key>, so it never collides with another plugin's or a core category. (Thelabelis what staff see in their preferences; it defaults to a humanized key.) - Source is enforced.
sourceis always set to your plugin id — a plugin can't impersonate the platform or another plugin, andlist/dismissonly ever touch your own entries. - Idempotency. A non-empty
dedupeKeymakescreatea no-op for ~10 minutes if the same key was already used (returns{ id: 0 }). Use it so retries/re-runs don't stack duplicates. - Rate limits. Creation is capped at ~60/min per plugin and ~300/min per shop; over the limit,
createthrows. The tray is low-volume by design — don't use it for high-frequency events. - Realtime. New entries push a live badge update to any open admin tab (best-effort; the UI also polls), so you don't manage delivery.
- Email delivery (preferences-driven). Besides the in-app tray, a notification can also be emailed to the shop's staff — but the plugin does not choose who. Each staff member controls, under Account → Notifications, which categories email them per shop. Owners/admins get your plugin's categories on by default (and can untoggle each one); other staff opt in.
createhonours those preferences automatically. Passemail: falseto suppress email entirely (in-app only); you cannot force an email past a user's opt-out (anti-spam). This is the preferred way to alert staff — prefer it oversw.email.notifyShop.
Audit Bridge (sw.audit)
Records an entry in the shop's audit log (the merchant's history of who changed what). Use it to leave a durable, human-readable trail of the consequential things your plugin does — an order it advanced, a record it synced, an external event it acted on — so the merchant can see your plugin's actions alongside the platform's own.
// A plain event — action + entity + message.
sw.audit.log({
action: "sync.completed", // REQUIRED — a short verb/label you choose
entity_type: "Product", // REQUIRED — what kind of thing it's about
entity_id: product.id, // optional — string or number
message: "Imported 412 products"
});
// A state change — pass before/after and the log shows exactly what changed.
sw.audit.log({
action: "order.flagged",
entity_type: "Order",
entity_id: order.id,
message: "Flagged for manual review",
before: { status: order.status, risk: "low" },
after: { status: order.status, risk: "high" }
});
actionandentity_typeare required;entity_id,message,before, andafterare optional. Choose anyactionlabel that reads well in the log (e.g."sync.completed","webhook.received").before/aftercapture a change. Pass both and the log displays only the fields that differ, so the merchant sees the exact transition. Callsw.audit.logright after you make the change.- Attributed to your plugin. Every entry is stamped as coming from your plugin — you can't post as the merchant, a staff member, or the platform.
- Throttled. Writes are capped per plugin (and per shop); over the limit,
logthrows. Record meaningful actions, not every loop iteration.
Event Bus (sw.bus)
A fire-and-forget event bus from your server-side code to your widgets. It lets a widget show the result of background work the instant it's ready instead of polling — the platform delivers each message to open widgets for you, so you never manage a connection.
It has two halves — the familiar emit/on pairing, but one-directional (server emits, open widgets receive):
- Server side (a route handler, a hook, or — most usefully — a
sw.task.bgclosure):sw.bus.emit(channel, data)pushes a small message. - Widget side (browser JS inside your widget iframe):
sw.bus.on(channel, cb)receives it. Returns an off function to stop listening.
// Widget: kick off background work, then wait for the push — no polling loop.
// The job id just has to be unique enough to pair this request with its push;
// the channel is already plugin-scoped, so it needs no cryptographic randomness.
// (crypto.randomUUID works too, but only in a secure context — https/localhost.)
const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const off = sw.bus.on(`job:${jobId}`, (result) => {
document.querySelector('#status').textContent = `Done — ${result.count} rows exported`;
off(); // one-shot: stop listening once we've got it
});
await sw.fetch('/start-export', { method: 'POST', body: JSON.stringify({ jobId }) });
// Route handler the widget POSTed to: do the slow work in the background and
// emit the result when it finishes.
module.exports = {
fetch(ctx) {
const { jobId } = JSON.parse(ctx.request.body || '{}');
sw.task.bg((bg, jobId) => {
const count = runLongExport(); // … minutes of work …
sw.storage.set(`export:${jobId}`, { count }); // durable: survives a closed tab
sw.bus.emit(`job:${jobId}`, { count }); // live: lets an open widget skip polling
}, { args: [jobId] });
return { json: { started: true } };
}
};
Semantics & limits
- One-way (server → open widgets).
emitis server-side only;onis widget-side only. A widget can't emit onto the bus — to talk to the server it already hassw.fetch. (Think of it as the inverse of polling, not a two-way socket.) - Channels are plugin-scoped. The name you pass is namespaced to your plugin before transport, so two plugins (or two shops) can never receive each other's messages. Use the bare name on both ends (e.g.
"job:abc") — the scoping is automatic. - Best-effort & ephemeral. It reaches every admin tab that is open right now; a tab that's closed when you emit never sees the message. Always persist the real result (
sw.storage/sw.files) and read it on the widget's next load — use the bus only to avoid polling while the user is watching. (This mirrors how the platform's own notification badge behaves: the bus nudges, but the source of truth is fetched.) - Small payloads only.
datamust be JSON-serialisable and ≤ 16 KB — it's a UI signal across a shared socket, not a data transport. Emit a reference (an id, a file URL) and let the widget fetch the bulk. - Rate-limited. Up to 120 emits/min per plugin (600/min per shop); over that,
emitthrows. - Admin widgets only. Delivery targets your plugin's widget iframes in the admin. It is not a storefront channel, and it does not deliver to other plugins.
emitis fire-and-forget. It returns{ ok: true }immediately; a transient transport failure is logged, not thrown (your real work has already completed). It is blocked insidesw.ledger.transact(like other side-effecting bridges).
Widget URL helper (sw.widget.url)
sw.widget.url(widgetId) returns the admin deep link to one of your plugin's widget pages (a widget with placement.page in the manifest) — e.g. "/widget/<yourPluginId>/<widgetId>". Use it as the link for an sw.notify entry (or an email) so clicking lands the merchant on the right screen, without hardcoding the route. Returns "" if widgetId is empty.
sw.notify.create({ title: "Setup required", category: "setup", link: sw.widget.url("settings"), severity: "warning" });
Record URL helper (sw.records.<type>.url)
sw.records.<type>.url(id) returns the admin deep link to a custom record's edit page — e.g. "/record/<type>/<id>". Use it as the link for an sw.notify entry (or an email) so clicking opens the exact record, without hardcoding the route. Returns "" if id is empty/zero.
const saved = sw.records.rfq.save(rfqData);
sw.notify.create({ title: "New price request", category: "requests", link: sw.records.rfq.url(saved.id) });
Container Jobs (sw.container)
Run an arbitrary container image as a one-off, isolated job for heavy work that doesn't fit a request or a sw.task.bg closure — image/video processing, ML inference, PDF/Pandoc, scraping, builds. Each job runs in its own ephemeral, isolated environment, so you can run arbitrary native code. Jobs are metered and billed by the job's size and runtime (plus network egress) on the shop's monthly invoice.
Availability — three gates, all required:
- Paid plan. The shop must be on a paid tier (Pro / Business / Enterprise / Dedicated). On the Free tier
sw.containerdoes not exist. - Manifest capability. Declare
"containers": trueinmanifest.json. Without it the namespace is omitted. - Shop opt-in (shop-paid only). The shop owner must enable Container jobs in shop settings (
Shop.ContainersEnabled, default off) — informed consent for paid compute. This gate is skipped for developer-paid plugins (container_billing: "developer"on a marketplace plugin): the shop isn't charged, so no cost consent is needed. It still applies when the shop pays (including a locally-installed copy that falls back to shop billing).
If any gate is unmet, sw.container is absent (calling it throws "not available"). A job is also rejected up front if the paying shop (see Who pays below) is already at its monthly compute cap, or if the request exceeds the per-job size / timeout / cost limits.
// manifest.json
"containers": true,
"container_billing": "shop" // "shop" (default) | "developer" — see "Who pays" below
// Launch a job. Returns immediately with a job id — it does NOT block on the run.
const { jobId } = sw.container.run({
image: "ghcr.io/acme/thumbnailer:1.4", // REQUIRED — any public registry image
cmd: ["/bin/run", "--out", "/tmp/x"], // optional: overrides the image entrypoint
env: { SRC_URL: srcUrl }, // optional: plain env vars (see limits below)
size: "standard", // optional: small | standard | performance
timeoutMs: 5 * 60 * 1000 // optional: ≤ your plan's max job timeout
});
// Poll status (own-plugin/own-shop scoped). status: running | done | failed | canceled.
const job = sw.container.get(jobId); // { status, exitCode, costCents, resultUrl, error }
// Fetch the job's logs (stdout/stderr), retrieved live from the runtime — works
// during the run and for ~7 days after (see Logs below). { logs, nextToken }.
const { logs } = sw.container.logs(jobId);
// Cancel a running job (stops the job, settles billing on measured time).
sw.container.cancel(jobId);
Runtime environment (locked down). Each job runs in its own ephemeral, isolated environment with no inbound network (no public ports/IP) and no persistent volumes; it does have outbound internet egress. Each job gets an ephemeral root filesystem (~8 GB, shared with your image layers) — budget roughly 5 GB of scratch for temp data (e.g. under /tmp). It's wiped when the job exits, so anything you need to keep must be written out via SW_UPLOAD_URL (below).
If your job opens a listening port, secure it yourself. Jobs have no public ingress, but they do share a private network with other jobs running at the same time. A process that binds a port (an internal server, debugger, etc.) can be reached by another job over that private network by IP — the private network is not a trust boundary. If you open a port, implement your own authentication/encryption (and bind to loopback if it's only for in-job use). Most batch jobs listen on nothing and don't need to worry about this.
Your env is passed through with two restrictions: keys beginning SW_ (and a small set of other runtime-reserved prefixes) are reserved and dropped (the platform sets SW_UPLOAD_URL/SW_TOKEN itself), and the whole env is capped (≤ 64 keys, ≤ 32 KB total) — an oversized env rejects the run().
Async lifecycle — react with the hook, not a blocking wait. run() returns a jobId immediately; the platform runs the job to completion in the background (serverless: nothing blocks on the job). When the job finishes and billing settles, the container.job.completed hook fires in a fresh request. Either implement that hook or poll sw.container.get(jobId) from a widget — never loop waiting inside a single script.
Writing artifacts (any number, any size). The job receives two env vars:
SW_UPLOAD_URL— a one-job-scoped mint endpoint for signed upload URLs.SW_TOKEN— a short-lived token (also already encoded inSW_UPLOAD_URL; provided separately forAuthorizationif you prefer).
Artifacts are uploaded directly to storage via signed URLs, so there is no size cap from our request/response limits and you can write as many files as you like. For each artifact, the image does two steps:
POSTtoSW_UPLOAD_URLwith the token and a JSON body{ "path": "<relative/path.ext>", "contentType": "<mime>" }. The response is{ "url", "method", "headers", "path" }.PUTthe raw artifact bytes tourlusingmethod(alwaysPUT) and the returnedheaders(send the exactContent-Typeyou requested, or the upload rejects the signature).
# inside the container image
RESP=$(curl -s -X POST "$SW_UPLOAD_URL" \
-H "Content-Type: application/json" \
-d '{"path":"thumbs/cover.webp","contentType":"image/webp"}')
URL=$(echo "$RESP" | jq -r .url)
curl -s -X PUT "$URL" -H "Content-Type: image/webp" --data-binary @cover.webp
Files land in the installing shop's file manager under a per-job folder (container-jobs/<jobId>/…); job.resultUrl is that folder's prefix, which the plugin (or the merchant) can list via the file manager. path is sanitized hard — no .., no escaping the job folder. No other credentials ever enter the job — not any platform credentials, not your sw.secrets, nothing but this single narrowly-scoped job token. The exit code is always captured (and the container.job.completed hook always fires), so a non-cooperating image still yields a result.
Logs / debugging a job. Your job's stdout + stderr are retrieved live from the runtime on demand — nothing to opt into and no need to upload them yourself. Call sw.container.logs(jobId) (returns { logs, nextToken }; pass nextToken back to page older→newer through long output), or open a job's logs from the admin Logs viewer (the container.run entry links straight to a live tail). Logs are available while the job runs and for ~7 days after it finishes (the runtime's retention window), including for failed/auto-destroyed jobs — so a bad input URL or a non-zero exit is debuggable after the fact.
Getting a file IN. The platform only hands the job an upload URL; feeding it an input file is your job, and the clean way is a short-lived signed download URL passed as an env var — the image just curls it. Nothing flows through request-size limits in either direction.
// server-side plugin code (e.g. a widget fetch handler)
const inputUrl = sw.files.signedUrl("public/scan.png", { ttl: 3600 }); // a file in Files
const { jobId } = sw.container.run({
image: "ghcr.io/acme/ocr:1",
env: { INPUT_URL: inputUrl }, // the image: curl -fsSL "$INPUT_URL" -o in.png
size: "small",
});
Worked example. The bundled demo plugin ships an Image OCR widget that does exactly this end to end —
sw.pickFilean image →sw.files.signedUrlit asINPUT_URL→ run an OCR image that uploadsresult.txt+meta.jsonviaSW_UPLOAD_URL→ acontainer.job.completedhook that firessw.notifyand pushes the signed result links oversw.busto the live widget. Seecli/plugins/demo/widgets/ocr.{js,liquid},hooks.js, and the reference job image incli/plugins/demo/ocr-image/.
Artifacts vs. compute — different owners. Uploaded files always belong to the installing shop and count against that shop's blob/file-manager quota, even when the developer pays for compute (next section). Files are the shop's; compute is the payer's.
Who pays the compute cost
The manifest container_billing field chooses whose monthly invoice is charged:
"shop"(default) — the installing shop pays. Its wallet is held at launch and billed on finish."developer"— the plugin publisher pays. For a published marketplace plugin that's the developer's own shop wallet, so you can offer container features without the merchant funding compute. (For a local / dev-installed copy of the plugin — a developer testing in their own shop — this falls back to the installing shop, since that is the developer's shop: development still costs money.)
The installing-shop gates (paid plan, concurrent-job limit) always apply regardless of who pays — concurrency is the installing shop's resource. The shop's container opt-in applies only when the shop pays (it's cost consent), so a developer-paid job runs without it. The monthly spend cap applies to whichever payer is billed. If a developer-pays plugin reaches its own monthly cap, run() throws a message telling the merchant the plugin's compute budget is exhausted (not theirs).
Recouping developer-paid compute. Charging the merchant back for compute you fronted is your plugin's concern, not a platform feature. Bill it however you price your plugin — e.g. a per-use
sw.ledgercharge against the shop, an in-app purchase consumable you decrement per job, or a subscription plan. The platform only moves compute credit between the launching gate and the paying wallet; any merchant-facing recoup is up to you.
Billing & caps.
- Postpaid. Jobs run without any upfront balance. On finish, the actual metered cost (wall-time + egress) is added to the paying shop's next monthly invoice;
costCentson the finished job is that charge. The monthly cap is the guardrail —run()is rejected once the payer is at its cap. - Caps (per plan tier): max job timeout, max per-job worst-case cost, max concurrent jobs (installing shop), and a per-shop monthly spend cap (paying shop). Exceeding any cap rejects
run()with a clear error. - Rate limit. Launches are hard-capped per shop per minute (fail-closed).
Security & egress. Jobs run arbitrary images in isolated environments with outbound internet access (egress). Treat anything the image can reach as reachable — don't pass secrets you wouldn't want a third-party image to see. Metadata (shop_id, job_id, plugin_id) is attached for attribution. sw.container is blocked inside sw.ledger.transact (it has external side effects).
Other Bridges
- Email:
sw.email.notifyShop({ role, subject, template, html, text, data })— deprecated: it emails staff directly, bypassing each user's per-category notification preferences. Prefersw.notify.create(declarenotify_categoriesin your manifest; its email channel respects each staff member's per-plugin preferences) for staff alerts. Still supported for now.sw.email.notifyCustomer({ customer_id, subject, template, html, text, data })remains the way to email a customer.sw.email.smtpSend({ host, port, username, password, secure, from, fromName, replyTo, to, cc, bcc, subject, html, text })— deliver a fully-formed email through a caller-supplied SMTP server (e.g. the shop's own mailbox), synchronously.portdefaults to587;secureis"tls"/"ssl"(implicit TLS, typical for port 465),"starttls"(require upgrade), or omit to opportunistically STARTTLS.to/cc/bccaccept a string, a comma-separated string, or an array. Providehtml,text, or both (both → amultipart/alternativemessage). Throws on a transport error. Connections to private, loopback, and link-local addresses are refused (an SSRF guard, re-checked at connect time to defeat DNS rebinding). Thehost,username,password,from,fromName, andreplyTofields support{secret.KEY}expansion (likefetch/sw.sql), so keep credentials insw.secretsand reference them inline — content fields (subject/html/text/recipients) are not expanded, so a secret can't leak into an email body. Pair it with theemail.sendhook +ctx.stop()to route all transactional mail through the shop's SMTP.
Transactional use only.
sw.emailis for transactional and operational messages (order confirmations, shipping updates, account notices, staff alerts). Bulk, mass, promotional, newsletter, or marketing sending is not permitted through this bridge — integrate a dedicated third-party email service viafetchfor mass mailing. See the Terms of Service (Email & Communications, Fair Use).
Execution Context & Settings
When a script is run, it is provided with an execution context object (ctx) and a global settings object containing the plugin's configuration.
// Access manifest setting "api_key"
const key = settings.api_key;
Timeout Awareness
Every script runs under an execution timeout the platform enforces by interrupting the run. The budget depends on what kind of script is running:
| Script type | Budget | Why |
|---|---|---|
Template render hooks (template.* hooks and {% hook %} tags) | 1 second | They run on the page-render critical path and block the visitor's page load, so they must be fast. |
Data/event hooks (*.before_save, *.after_save, checkout/cart/payment/search hooks, etc.) | 5 seconds | Realtime hooks that guard API writes. Enough headroom for one external call (fraud, tax, address validation), but heavy work belongs in sw.task.bg. |
Route handlers & widget fetch(ctx) | 60 seconds | Request handlers, matching the platform's request deadline; gives room for outbound calls and computation. |
Lifecycle hooks (plugin.activate/deactivate/uninstall/change_version) | 60 seconds | Network provisioning / teardown of external resources. |
Background & scheduled scripts (sw.task.bg, cron) | 10 minutes | Long-running batch work. |
Outbound fetch() calls (connect + response headers) are bounded at 60 seconds — body streaming is bounded by the run's remaining budget, not this limit.
Use ctx.timeoutRemaining() to check how many milliseconds remain before the engine forces a timeout.
For simple scripts, you can break out of a loop to stop gracefully:
if (ctx.timeoutRemaining() < 5000) {
console.warn("Stopping early — only " + ctx.timeoutRemaining() + "ms left");
break;
}
For scripts that need to process everything, use sw.task.continue() instead to resume in a new task (see Task Continuation above).
Logging
console.log, console.info, console.warn, console.error, console.debug, and console.trace are captured and stored in the admin UI log viewer. Each call records at its named level.
Shop log level
The shop's Log Level (Settings → Developer → Log Level) controls which messages are recorded. Lower levels are quieter:
| Level | Records |
|---|---|
error | errors only |
warn | warnings + errors |
info | normal logs (default — empty value also means info) |
debug | adds console.debug |
trace | adds console.trace, console.time/timeEnd, and automatic timing of every hook, export, and sw.* bridge call |
Messages above the configured level are dropped before they hit storage, so leaving console.debug/console.trace calls in production code costs nothing when the shop sits at info. Errors thrown by scripts are always recorded regardless of level.
Timing your own code (console.time / console.timeEnd)
Standard JS timer API. Both calls are present at every level but only emit a log row when the shop is at trace — leave them in place safely.
module.exports = {
"order.before_save": function (event) {
console.time("fraud-check");
const verdict = fetch("https://fraud.example.com/check", {
method: "POST",
body: JSON.stringify(event.data)
}).json();
console.timeEnd("fraud-check"); // TRACE row: "fraud-check: 312ms"
if (verdict.block) throw { error: "Order blocked" };
}
};
Labels are per-run, so two scripts can use the same label without colliding.
Automatic bridge & hook tracing
When the shop is at trace, the engine records timing for every script entry point and every sw.* bridge call automatically — no instrumentation needed. A typical trace stream in the log viewer looks like:
TRACE [acme] render hook product.before_render: 142ms
TRACE [acme] sw.sql.query: 138ms ← slow plugin identified
TRACE [other] hook order.placed: 9ms
TRACE [acme] export nightly-sync: 4821ms
The existing pluginID column in the log viewer makes it easy to see which plugin is spending the most time. Turn trace off when you're done investigating — log volume is high.