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, unpaid-order recovery — live in Recipes.md.

Where your plugin lives, and how to work on it

A plugin installed on a store is a package — the whole directory, uploaded as one — plus any edits made on the store since, from the admin's file editor or from an AI assistant a merchant has connected. Edits sit on top of the package: the store runs the edited copy of a file and the package copy of everything else.

That matters as soon as more than one person (or one assistant) touches a plugin, so the CLI treats it the way you'd expect a version-control tool to.

shopswired login             # authenticate (opens the browser, no password in the terminal)
shopswired link              # pick the store to develop against

shopswired init  --type plugin --id my-plugin      # scaffold a new one
shopswired pull  --type plugin --id my-plugin      # bring the store's current copy down
shopswired push  --type plugin --id my-plugin      # send this directory up as the package
shopswired dev   --id my-plugin                    # live development (below)

pull gives you what the store is actually running — the package with the store's edits already applied, as plain files. It's the honest starting point whether you're picking up someone else's plugin or catching up on what an assistant changed.

push replaces the package with your directory and clears the store's edits. That's what makes a push mean something: without it, a file edited on the store would keep shadowing the one you just pushed, and your push would appear to succeed while changing nothing.

Because that discards work, push checks first — exactly like pushing to a shared branch:

✗ 2 file(s) changed on the store since this directory last synced:
    edited  scripts/hooks.js
    added   tools/report.js
  These were edited in the admin or by an assistant. Pushing now would replace them.
  Run 'shopswired pull --type plugin --id my-plugin' to bring them in,
  or re-run with --force to discard them.

Pull, look at what changed, push. Or --force when you know the store's copy is the one to drop. The comparison is against what this directory last synced (a .shopswired-sync.json written by pull and push — add it to .gitignore), so your own local edits are never mistaken for someone else's; only changes made on the store stop a push.

A directory that has never synced — an existing checkout, or a fresh clone of the repo you've been pushing from all along — is not treated as suspect. The store still knows which files are edits on top of its installed package, and those are the only ones a push would destroy: if there are none, the push goes through as it always did.

Working entirely on the store. You don't have to use the CLI at all. The admin's file editor and a connected assistant both edit the store directly, and the change is live immediately — no push, no install. What they leave behind is that layer of edits, marked MOD in the file editor.

When a set of changes is finished, fold the layer into the plugin itself: the file editor offers a 📦 action while a plugin or theme has edits, and an assistant has the same one. The plugin keeps running exactly as it was; it simply stops being a package plus a pile of edits and becomes one thing again — downloadable, publishable, and safe from being shadowed by a later install of the same plugin. A pull afterwards gives you those files with nothing layered on top, which is how store-side work gets into a git repo.

This applies only to a plugin or theme the store owns. A marketplace install keeps its edits as a layer on purpose — that layer is exactly what survives the publisher's next update. Clone it first if you want to develop your own copy.

Two workflows, and you can use both:

  • Live developmentshopswired dev watches your directory and the store runs your local files directly, saving on each change with no push and no install. Nothing is written to the store, so ending the session puts it straight back to its installed copy. This is the fast loop for writing and debugging.
  • Keeping a repositorypull into a git repo, commit, work locally, push when you want the store to have it. The package is one directory of plain files, so it diffs and reviews like any other source tree. If a merchant or an assistant edits the plugin on the store, the next pull brings it into the repo as a normal commit.

Themes work identically — same commands with --type theme.

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 every script: scheduled scripts, durable bg tasks (sw.task.bg), route handlers, widget scripts, hook scripts, sw.task.run runs, and storefront render scripts. On the render path, keep the module tree small — see Template Render Hooks.

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

Scoping a script makes the page faster, not just quieter. On a page a script's routes/dataloaders exclude, the script is skipped entirely — its top-level code never runs there, so anything it does at load time (building a lookup table, reading settings) happens only on the pages it declared. Scope your storefront scripts and the rest of the store stops paying for them. Two things keep loading regardless, by design: a script that exports any filter.* (filters are available to every page's Liquid, so they can't be route-scoped), and a script that declares templates (the template can still change while the page renders). A script whose exports are all non-render — order and product hooks, scheduled work, route handlers — never loads during a page render at all; put storefront code and back-office code in separate files and each page only pays for what it uses.

  • 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 for private-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 a schedule, or one inheriting the plugin-level schedule) are runnable this way; hook/route/widget scripts and internal lib files 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 as ctx.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
    console.log(`reindex force=${force} batch=${batch}`)
    // ...
}
  • label: (Optional) Text shown on the Run button; defaults to the script path.
  • export: (Optional) Entrypoint function name; defaults to run.
  • 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's default applied when blank) are available as ctx.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.

Example: the rinven plugin's dedup.js is an action with a dry_run checkbox param that overrides the global setting for a single run.

AI assistant tools ("mcp": true)

A merchant can connect an AI assistant to their store, and your plugin can give it tools of its own. There are two shapes, and the difference is whether the assistant waits for an answer.

Tools that answer (tools)

Add "mcp": true and a tools array to any script, and each declared export becomes a tool. These run in the foreground and whatever the export returns goes straight back to the assistant — so it can ask, read the answer, and decide what to do next in the same turn. One file can carry a whole related set.

{
    "scripts": [
        {
            "path": "tools/inventory.js",
            "mcp": true,
            "tools": [
                {
                    "export": "lookupSku",
                    "label": "Look up a SKU",
                    "description": "Return current stock, price and warehouse location for one SKU. Use before adjusting stock so you know the starting point.",
                    "read_only": true,
                    "params": [
                        { "key": "sku", "type": "text", "label": "SKU" }
                    ]
                },
                {
                    "export": "adjustStock",
                    "label": "Adjust stock",
                    "description": "Add or remove stock for a SKU and record why. Use a negative delta to remove.",
                    "params": [
                        { "key": "sku", "type": "text", "label": "SKU" },
                        { "key": "delta", "type": "number", "label": "Change", "help": "Positive adds, negative removes" },
                        { "key": "reason", "type": "text", "label": "Reason", "default": "manual" }
                    ]
                }
            ]
        }
    ]
}
// tools/inventory.js
const findStock = (sku) => {
    const page = sw.records.stock.list({ filters: { sku }, limit: 1 })
    return (page.items || [])[0] || null
}

module.exports.lookupSku = (ctx) => {
    const item = findStock(ctx.params.sku)
    if (!item) return { found: false, sku: ctx.params.sku }
    return { found: true, sku: item.sku, stock: item.stock, price: item.price }
}

module.exports.adjustStock = (ctx) => {
    const { sku, delta, reason } = ctx.params
    const item = findStock(sku)
    if (!item) throw new Error(`No stock record for ${sku}`)
    const stock = item.stock + delta
    sw.records.stock.save({ ...item, stock })
    console.log(`${sku} ${delta > 0 ? '+' : ''}${delta} (${reason}) → ${stock}`)
    return { sku, previous: item.stock, stock }
}
  • export: Required. The exported function's name. It receives ctx and returns the value the assistant sees. Only exports listed here are callable — that named-entry-point rule is what keeps a tool call from being a way to run arbitrary code in your plugin, the same reason routes and hooks are declared rather than discovered.
  • description: Required. One or two sentences saying what the tool does and when to use it, written for the model that has to choose between tools. An entry with no description is not published.
  • label: (Optional) Short human name; defaults to a humanized export.
  • read_only: (Optional) Declares that the tool only reads. It's a hint that helps an assistant decide what's safe to call while exploring — it is not an enforced restriction; what your script can do is still decided by the bridges it calls.
  • params: (Optional) The tool's arguments, reusing the settings field schema. They arrive as ctx.params, with declared defaults applied and types coerced.
  • The name the assistant sees is plugin_<your-plugin-id>_<export>, lowercased with anything unusable replaced (alt-inventory + lookupSkuplugin_alt_inventory_lookupsku). The platform's own tools are all named sw_…, so yours can never collide with one — but two plugins on the same store can, and the first one loaded wins. Keep exports distinctive.
  • Return anything JSON-shaped. Objects, arrays, strings and numbers are all handed back as-is. Return the facts rather than a sentence — the assistant writes the prose, and a structured answer is what lets it chain into the next call. Throwing an error returns that message to the assistant, so make errors say what to do differently (No stock record for ABC-1 beats failed).
  • Keep them quick. A tool runs inline while a caller waits, so it gets the short inline run budget. Anything long-running belongs in an action (below), which is dispatched to your store's queue.
  • The plugin must be active, and the caller needs the plugins capability — the same gate as the rest of your plugin's admin surface.

A script can declare tools and still be a normal hook file. Publishing tools doesn't change anything else about it.

Offering an action to an AI assistant

An action can be published too. Add "mcp": true and a description to it, and the assistant can run it by name — "sync the warehouse" becomes something the merchant can just ask for, with your params as the arguments. Unlike the tools above, an action runs in the background: the assistant is told it started, and the output goes to the Logs tab. Use this shape for work that takes a while and has nothing to return.

{
    "path": "reindex.js",
    "type": "action",
    "label": "Reindex catalog",
    "mcp": true,
    "description": "Rebuild the search index from the current catalog. Use after a bulk import, or when search results look stale.",
    "params": [
        { "key": "force", "type": "checkbox", "label": "Force full reindex", "default": false },
        { "key": "batch", "type": "number", "label": "Batch size", "default": 500 }
    ]
}
  • mcp: (Optional) Offers this action to a connected assistant, while the plugin is active. Opt in deliberately — an action written for a person standing at a Run button isn't automatically one an assistant should call unprompted.
  • description: Required when mcp is true. One or two sentences saying what the action does and when to use it. This is what an assistant reads to decide whether to call it, so write it for that decision, not as a button caption — label already covers the button. An action marked mcp with no description is not offered.
  • Your params become the arguments. Each declared field is described to the assistant from its label and help, and typed from its type: checkbox is a boolean, number a number, select becomes a fixed list of its options, model an id. A field with a default is optional; a field without one is required, since there's nothing to fall back to. group, menu and link entries are presentational and aren't offered as arguments. Give every field a clear label and a help line — vague labels are the main reason an assistant supplies the wrong value.
  • It runs exactly as it always did. The assistant's call goes through the same background run as the Run button, with the same permission check, the same declared-scripts rule, the same budget, and the same Logs output. Nothing new executes, so an action that's safe to run from the admin is safe here.
  • Assume it may be called more than once. Write actions to be idempotent and safe to re-run, and prefer a dry_run-style param for anything expensive or destructive so the assistant can check its work before committing.

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:

KeyPurpose
nameField id — how you read/write it (ctx.data.<name>, sw.records payloads, filter keys). Required.
typestring, textarea, richtext, number, integer, boolean, date, datetime, tags, image, model, json, widget, or a region picker (country / us_state / ca_state). Defaults to string.
labelHuman label shown as the column header and edit-form label. Falls back to name.
listtrue shows the field as a column in the record list.
indextrue 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.
hiddentrue keeps the field out of the record list; helper/composite fields use it to stay out of the edit form too.
uniquetrue rejects a save whose value duplicates another record of the same type.
optionsFixed set of allowed string values — renders as a dropdown in the edit form and as a dropdown filter in the list.
model + multipleFor type: "model": which entity the field references (product, customer, order, coupon, …, or custom:<type>) and whether it holds one id or many.
widgetFor type: "widget": the id of one of your own widgets that renders/edits this field on the record edit page, instead of the built-in JSON editor. The value is stored like a json field (structured, not filterable). The widget must declare a placement.detail entry with "entity": "custom:<type>" and "mode": "field" — see Record-field widgets.

Reading a multiple field. A model field declared "multiple": true holds a list of ids — but one holding exactly one id reads back as that bare id, not a one-element array. Accept both shapes wherever you read such a field (a script, a hook, a template):

const raw = post.products;
const ids = Array.isArray(raw) ? raw : (raw ? [raw] : []);

Filtering is unaffected — an equality filter on a single id matches any record whose field holds it, however many ids it holds.

Relations (relations[]). A record type can declare how its fields point at other records, and those show up as reverse links: on the target's detail page (an order, customer, product, coupon, or another custom record), the Related Records tab lists your records whose declared field holds that id. Each entry is { "target": "order" | "customer" | "product" | "coupon" | "custom:<type>", "field": "<field name>", "label": "...", "multiple": true }; the field must be indexed (a model-typed field is, by default). A relation on a hidden field also gets the record's edit form a Relations tab holding a picker for it (a multi-select when multiple is set), so a field that has no other editing surface is still settable by hand. A relation on a visible field doesn't — that field already renders its own picker on its own tab, and isn't repeated. The bundled reviews plugin declares {"target": "product", "field": "product_id", "label": "Reviewed Product"}, so a product page's Related Records shows its reviews; the blog plugin declares a multiple relation on its featured products field, so a product page also lists the posts featuring it.

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, or subscription (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_time tiers 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.)

Reading the running version — ctx.version

Every plugin-executed script (hooks, routes, widgets, scheduled runs, payment hooks, storefront render hooks and filters) receives the installed plugin's own version — the version from its manifest.json — as ctx.version:

module.exports.hook_product_save = (ctx) => {
    // e.g. gate a migration or a new code path on the version the shop is running
    if (ctx.version === '2.0.0') { /* … */ }
    console.log(`running ${ctx.version}`);
}

It's the exact string from the manifest (typically semver, e.g. "1.4.2"), and the empty string '' for an unversioned plugin. Use it to log the running build, or to branch on data written by an older version during an in-place upgrade.

Reading what triggered the run — ctx.source

Every plugin-executed script receives ctx.source, naming who caused the work:

ctx.source = {
    type: "user",        // who: user | plugin | support | mcp | system
    user_id: 42,         // the signed-in staff member, when there is one (absent otherwise)
    plugin: "gifting",   // the plugin whose code performed the write (absent otherwise)
}
typeMeans
userA staff member — an edit in the admin, or an API call with their token
pluginA plugin acting on its own: a scheduled script, a background task, a payment webhook
supportPlatform support working in the store on the merchant's invitation
mcpAn AI assistant connected to the store
systemThe store itself — checkout and other storefront activity, renewals, imports, internal jobs

type and plugin answer different questions and are often both set: a staff member running a plugin's action is type: "user" with plugin naming the plugin whose code did the writing. So test ctx.source.plugin — not type — to tell a plugin's write from a hand edit:

module.exports = {
    "product.after_save": (ctx) => {
        if (ctx.source.plugin === "my-plugin") return;   // my own write — don't react to it
        if (ctx.source.plugin) { /* another plugin changed this product */ }
        else { /* a person changed it in the admin */ }
    }
};

That check matters most in a CRUD hook, where your handler sees every writer including itself. For shopper-driven storefront activity, type is system — identify the visitor with ctx.customer and ctx.request instead.

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 with sw.iap.entitled(key).
  • consumable — a credit balance you debit with sw.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 includes token) to your widget's client JS and call the bundled await sw.iap(token) client helper. The admin renders the approval modal in place (like sw.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.

A complete implementation is three pieces: the iap_products manifest block, an iap.purchase hook that grants the entitlement or credits, and a dashboard widget that shows the current status and requests purchases.

Settings

The settings array defines configuration options for your plugin. Supported types include text, textarea, number, checkbox, select, image (URL input with a Browse button that opens the file picker), folder (a folder-path input with a Browse button that opens the folder picker — the merchant chooses a folder and the field stores its path, e.g. public/blog, ready to pass to sw.files.list), 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" }
}

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), or true / false. Comparison is strict equality against the referenced field's current value.
  • Only == is supported — there is no !=, &&, ||, or present. 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 condition to clear a value; read the field your logic actually needs (e.g. fall back across state_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 no tab fall on a General tab. 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 same group label, 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. label defaults to key.

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 on ctx.permissions, not on ctx.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. A typical dashboard widget gates its recent-orders panel on a view_orders grant and its revenue line on a view_financials grant, skipping the underlying lookup entirely when the grant is absent.

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

Customer impersonation ("Log in as customer") is a native merchant feature, not a plugin capability — a user with the dedicated impersonation permission (its own role toggle, separate from the customers capability) opens the customer's storefront session straight from the customer's admin page (see Features.md). There is no plugin bridge for it.

What's relevant to plugins:

  • Order attribution. Orders placed while acting as a customer — and orders composed in the admin order builder — are stamped with order.created_by_user_id = the staff member who acted (see Orders), for attribution/commission.
  • Granular roles. A shop owner mints custom roles (e.g. Sales Rep, Viewer) with read/write/full per resource; built-in roles are owner/admin/staff. Attach a permission your plugin declares to a role so it propagates to ctx.permissions for everyone in it (see Permissions).

Event Hooks

Scripts export functions to hook into system events. The export key defines the hook name.

Need the exact shape of ctx or ctx.data? Entities.md is the field-level reference: the full ctx object (hooks, widgets, routes, tasks), a per-hook table of what ctx.data holds, and every built-in entity's fields.

Data Hooks

Hooks allow you to intercept saves and deletes. Every built-in entity fires the same four CRUD events — before_save / after_save / before_delete / after_delete:

  • product.* — e.g. product.before_save, product.after_delete
  • order.*
  • customer.*
  • coupon.*
  • wired_fulfillment.*
  • record.<type>.* — for custom record types (see below).

ctx.data is the entity (the deleted entity on *_delete), ctx.old_data its previous state (absent on create) — see Entities.md for per-entity shapes. before_* can mutate ctx.data or throw to abort; after_* throws are logged but don't roll back the write.

Every writer fires them — a merchant edit in the admin, an API call, and any plugin's sw.* save alike. Your product.after_save sees another plugin's sw.products.save exactly as it sees an admin saving the same product.

Hooks fire one level deep. A save made by a CRUD hook — sw.orders.save from inside a product.after_save handler, sw.records.<type>.save from inside another record's hook — is written normally but fires no further hooks. Without that, two plugins reacting to each other's writes would bounce a record back and forth for the rest of the request. Writes from anywhere that isn't itself a CRUD hook — a route handler, widget, scheduled script, background task, or template.before_render — start a fresh level, so those fire hooks as usual. That is also the escape hatch: if you need a save whose own hooks must run, make it from sw.task.bg, a fresh run whose chain is depth-capped rather than unbounded.

CRUD hooks don't fire for the store's own atomic counter updates. A few internal writes adjust a single number across several records at once and must commit as one indivisible unit — coupon usage when an order is placed, stock decrements at checkout, running totals. Those writes skip plugin hooks: nothing about the record changed that a plugin could meaningfully review, and script running inside an indivisible update would be re-run whenever the update is retried under load. React to the event that caused it instead — e.g. use order.after_save (or the checkout hooks) rather than coupon.after_save to observe a coupon being redeemed. Merchant and API edits to those same records fire hooks normally.

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_save runs before the schema pass that builds composite index fields and allocates the id, so on a create ctx.data.id is still 0 (populated by after_save). Any composite index field is (re)computed from your final field values after before_save, so defaulting a member field here (as the Blog plugin does for published_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 keys id/kind/created/updated are read-only and ignored). Returning a value is not required.
  • Throwing aborts before_save/before_delete (surfaced as a 403); 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.address carries the destination country, state, zip, and city — the initial checkout render rates the customer's saved address, and the bundled default theme's address recalc POSTs country/state/zip/city to /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) replace ctx.data.options with 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 in checkout.before_create (its order.shipping always has the complete address). Each option is { id, name, price, type, price_note?, pickup? }; when an option's final price isn't known yet (a freight/quote rate you'll settle later), set price to 0 and price_note to a short string such as "—" — the storefront shows that note in place of the amount, so the placeholder isn't rendered as "Free". The hook does not run when every line in the cart is a digital product — such an order has nothing to deliver, is shown "Shipping N/A", and is never billed shipping.
  • 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_create can 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 include redirect_url to bounce the customer.
  • subscription.before_renew: Decide what a recurring contract bills this cycle, for plans the store owner made adjustable (see below).

subscription.before_renew — what this cycle bills

For a subscription whose amount isn't the same every time: weekly music lessons, where October has four and December has five. It fires once per renewal, just before the charge, and your answer goes straight onto that renewal's invoice.

It only fires for contracts on a plan the store owner made adjustable — that is, one with bounds (see Entities.md). An ordinary fixed-price subscription never reaches your handler at all.

module.exports['subscription.before_renew'] = (ctx) => {
    const sub = ctx.data.subscription;

    // ctx.data.period_start / period_end are the window this charge covers, so
    // you count the same days the store would.
    const lessons = countScheduled(sub.customer_id, ctx.data.period_start, ctx.data.period_end);

    // Reassign the array — mutating the one you were given is not picked up.
    ctx.data.items = ctx.data.items.map(line => ({ ...line, qty: lessons }));
};

ctx.data carries:

KeyWhat it is
subscriptionThe full contract, including its bounds and max_cycle_amount.
itemsThe contract's current lines, already shaped as { product_id, set, qty } — start from these.
boundsThe range you're allowed to move within.
period_start / period_endThe window this charge covers.
previewtrue when you're being asked what you'd bill, false when the charge is about to happen. Always present.

The period bounds are moments, not strings. period_start and period_end are the store's own date/time values: JSON.stringify gives you the standard 2026-10-01T09:00:00Z form, and .unix() gives seconds. Don't treat one as a string directly — string-building on it produces a plausible-looking date that is silently wrong, and a window that doesn't line up with the calendar bills the wrong number. Convert once, at the top of your handler:

const asMs = (t) => (t && typeof t.unix === "function") ? t.unix() * 1000 : Date.parse(JSON.stringify(t).slice(1, -1));
const from = asMs(ctx.data.period_start), to = asMs(ctx.data.period_end);

Treat the window as half-open[period_start, period_end). One cycle's end is typically the next one's start, so anything landing exactly on period_end should be counted by the next cycle, not this one. Include both ends and every boundary item is billed twice.

Set ctx.data.items to reply. Each entry is { product_id, set?, qty, price? }:

  • Lines are matched to the contract by product (and set, when the same product appears more than once). You adjust the contract's lines; you can't add or remove products — a line matching nothing is rejected rather than ignored.
  • Omit a line entirely to leave it at its standing quantity and price.
  • Send price (per unit, in cents) only when the plan made price adjustable.
  • Set qty: 0 to bill nothing for that line. Zero everything and the cycle is skipped: no charge, no invoice, and the schedule simply moves on. Only possible when min_qty is 0.

Everything you send is checked against the contract's bounds and against the maximum the customer agreed to at signup. Anything outside them is rejected, not trimmed to fit — a quantity of 50 against a maximum of 5 stops the renewal and flags it for the store owner rather than quietly billing 5. Fix the cause and the owner resumes billing; nothing is charged in the meantime.

ctx.data.preview is not decoration — check it if your handler writes anything. The same handler answers both "what will this cost?" (when a store owner opens the subscription, or a customer opens their account page) and "bill this now". If yours locks a roster, marks sessions as invoiced, or writes a ledger entry, it must do that only when preview is false — otherwise it corrupts itself every time someone loads a page.

module.exports['subscription.before_renew'] = (ctx) => {
    const lessons = countScheduled(/* … */);
    ctx.data.items = ctx.data.items.map(line => ({ ...line, qty: lessons }));

    if (!ctx.data.preview) {
        markInvoiced(/* … */);   // the real thing — never on a preview
    }
};

A preview asks the same question through the same bounds checks, so what the store owner and the customer are shown is necessarily what gets charged. Keep the handler side-effect-free where you can and this costs you nothing.

Three more things worth knowing:

  • Throwing delays the renewal; it never bills the wrong amount. A handler that fails is retried on the usual slow schedule, and the contract carries a visible reason in the meantime. Nothing falls back to the plan price.
  • Answering with nothing (no ctx.data.items, or event.stop()) bills the contract's standing amount — unless the plan is marked variable, in which case the renewal is held for the store owner instead. A variable plan's stored price is a placeholder, so billing it would charge for four lessons in a month that had none.
  • Telling the customer early is yours to do. The store doesn't send an advance notice for a changing amount — you know the number before the store does, so send it yourself with sw.notify.customer when it matters to your plugin.

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:

  1. The shop defines named levels in shop.price_levels (admin/config), each with a key, optional label, and a default fallback formula:
    • mode: "named_only" (default) — only products that have an explicit prices[key] entry get a special price; everything else stays retail.
    • mode: "percent"value is basis points off the retail price (1500 = 15% off).
    • mode: "amount"value is cents off the retail price.
    • show_compare: true — sets compare_price to 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% off clearance, 10% off Material=Gold). Evaluated before the level's default mode/value.
  2. Each product's prices map ({"wholesale": 1999, "dealer": 1799}) holds explicit per-level prices. An explicit entry always wins over rules and the default formula.
  3. A customer is assigned a level by setting price_level on 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 on ctx.data.payment:

    • ctx.data.payment.id — Payment provider's transaction ID
    • ctx.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 checkout
    • ctx.data.payment.data — (Optional) An opaque object passed straight through to your gateway's own client script — the platform never reads it. Use it for payments that must be confirmed in the browser (wallets like Cash App Pay, 3-D Secure cards, BNPL like Klarna/Afterpay — anything that can't be confirmed server-side). See Browser-confirmed payments (authorize → capture) below for the full contract; in short, on the authorize step set status to "pending" and put whatever your client needs to confirm (e.g. a client_secret) here. The platform relays it to the checkout client on the checkout_authorize event as e.detail.payment_data (and in the create-payment-intent result as payment_data); your element confirms with it and pushes a Promise onto e.detail.promises. This keeps the checkout contract gateway-agnostic — no gateway-specific field on the platform response.

    Recurring (subscriptions). When the order contains a subscription line the platform sets directives on the input ctx.data.payment that 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 on ctx.data.payment.vault_token (an opaque string the platform stores securely and never parses, e.g. a JSON blob of customer + payment-method ids). The platform keeps it as the customer's card on file. Optionally also set a non-secret display hint on ctx.data.payment.card so the saved method shows a label in the admin and storefront: a card sets { brand, last4, exp_month, exp_year }; a non-card method (Cash App Pay, Link, a bank debit, …) has no card fields, so set { label: "Cash App Pay" } instead.
    • ctx.data.payment.trial — vault the method but don't charge now (free trial); set status to "paid" for the $0 order.
    • ctx.data.payment.off_session + ctx.data.payment.vault_token — charge a stored method with no interactive step: subscription renewals, the admin "charge on file" action, and a shopper paying with their saved method at checkout all use this. Set status to "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, from payment.webhook_account — see below). Set one of:

    • Payment update: set ctx.data.order_id (e.g. from the provider's reference_id/metadata you stamped at create_intent) plus ctx.data.payment_id and ctx.data.payment_status. Use "paid" to confirm, or "failed" for an async failure (e.g. an ACH return) — the platform then marks the order payment_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. When a charge that vaulted a method settles asynchronously (a subscription signup, or any browser-confirmed payment that saved the method), also set ctx.data.vault_token on the "paid" event so the platform stores it as the customer's card on file (and can create the subscription) — plus an optional ctx.data.card display hint, the same shape create_intent returns — a card's { brand, last4, exp_month, exp_year } or a non-card method's { label }. (For a synchronously-confirmed charge the token comes back from create_intent instead.)
    • 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 from payment.refund) and ctx.data.refund_status ("succeeded" or "failed"). You don't need order_id — the platform stores the provider refund id on the order and resolves the order from refund_id (pass order_id too if you happen to have it). It flips the matching refund from pending to its final state, and only then marks the order refunded, sends the refund email, restocks, and reverses any wired-supplier ledger. If a refund_id is 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_intent sets ctx.data.payment.{id,status,url,client_secret}. A hook that reports discrete facts about an external event sets flat, prefixed fields: payment.webhook sets payment_id/payment_status (a payment event) or refund_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.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.data carries:

    • amount — positive cents to refund (already validated against the remaining balance; may be less than the order total for a partial refund)
    • currency — order currency
    • reason — admin note
    • payment_id — the original charge/payment id
    • idempotency_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 id
    • ctx.data.status"succeeded" (money returned) or "pending" (provider settles later and will confirm via payment.webhook)

    Throw (or ctx.preventDefault(msg)) to reject the refund; the platform records the attempt as failed with 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 no sw.* globals — that extracts the provider account id from a no-shop webhook body so the platform can resolve the owning shop before running payment.webhook. Read ctx.data.body and set ctx.data.account_id (e.g. ctx.data.account_id = JSON.parse(ctx.data.body).merchant_id). Pair it with sw.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 from ctx.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).

Browser-confirmed payments (authorize → capture)

A method that can only complete in the browser (wallets like Cash App Pay, 3-D Secure cards, BNPL like Klarna/Afterpay) can't be charged in one server-side call. For these, the platform runs checkout payment-first: it authorizes the payment from the cart, has the shopper confirm it in their browser, and creates the order only after confirmation succeeds — so abandoning a wallet or BNPL step leaves no order behind. Your payment.create_intent sees this as two calls, signalled by flags on the input ctx.data.payment:

  1. Authorizectx.data.payment.prepare === true. There is no order yet (ctx.data.order.id is 0, and its totals reflect the cart). Create an authorization for ctx.data.order.totals.total, set status to "pending", set ctx.data.payment.id to the authorization id, and put your client's confirm payload on ctx.data.payment.data (e.g. { client_secret }). Do not charge yet. Add save_method/trial handling here if the order needs vaulting (below).
  2. Capturectx.data.payment.capture === true with ctx.data.payment.intent_id set to the id you returned in step 1. Now the order exists (ctx.data.order.id is real). Read the authorization back and report the outcome: status "paid" (done), "pending" (an async method still settling — your payment.webhook finishes it), or "failed". Verify the authorized amount equals ctx.data.order.totals.total before settling, and fail on a mismatch — this is your anti-tamper check, since the authorization was sized from the cart before the order existed. Stamp the now-known order.id onto the payment (e.g. in provider metadata) so an async payment.webhook can resolve it.

The client contract (both at checkout and when paying an existing order): the platform fires a checkout_authorize window event carrying e.detail.payment_data (your step-1 payload), e.detail.provider and e.detail.return_url. Your element script — after checking e.detail.provider is yours — calls e.preventDefault(), confirms the payment, and pushes a Promise onto e.detail.promises that resolves on inline success (card/Link) or rejects on failure. A redirect method (Klarna, mobile Cash App Pay) instead sends the shopper away and back to return_url; the platform completes the order server-side on return. Either way the order is created (and step 2 runs) only once payment is authorized.

A gateway that instead collects a single-use token in the browser and charges it in one server-side call (no browser confirmation) doesn't use any of this: it just submits the token as meta[payment_token] from your element (via e.detail.meta.payment_token on checkout_pre_submit) and charges it in a plain create_intent (no prepare/capture) as the order is created. Set neither data nor a checkout_authorize listener.

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 report order_id.

  • Connected-account gateways (one app connects many sellers, e.g. Square): register the single URL …/payment-webhook/square-payment/gateway/square once at the provider's app. There's no shop in the URL, so the platform resolves it in two steps before payment.webhook ever runs:

    1. 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.
    2. On each webhook, the platform runs your payment.webhook_account hook — a bridgeless parser with no sw.* 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 runs payment.webhook in 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 in plugin.uninstall.

    Because the hook ends up namespaced to the seller's shop, per-shop sw.storage and the per-shop access_token resolve normally; app-level signing secrets live as platform (NS 0) secrets and resolve via the marketplace-secret fallback.

Secrets referenced as {secret.KEY} are expanded at the HTTP/crypto boundary — they're substituted inside fetch headers/body and in the key argument of 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:

Only the key expands, never the message. A placeholder passed to .update(...) (or createHash().update(...)) is hashed literally — those bytes are your data, not a lookup. If a signing scheme puts a stored value in the signed message rather than the key (Square signs notificationUrl + body), that value has to be a "readable": true secret or setting you read with sw.secrets.get(...) and concatenate yourself. There is no error when you get this wrong: the placeholder text just gets signed and every signature check fails.

crypto is a top-level global, not sw.crypto. Like fetch and FormData, it lives at the script root — use crypto.createHmac(...) / crypto.timingSafeEqual(...). sw.crypto is undefined and throws "Cannot read property 'createHmac' of undefined".

createHmac('sha256', secret).update(data).digest(encoding) supports 'hex', 'base64' and 'base64url' encodings (Stripe uses hex; Square uses base64).

Hashing without a key. crypto.createHash('sha256').update(data).digest(encoding) is the unkeyed counterpart, with the same update/digest shape. Use it where a scheme asks for a digest of a request payload rather than a signature of it.

Derived-key chains (createHmac's third argument)

Some signing schemes derive their key in steps, feeding each HMAC's output in as the next one's key. Passing a digest back as a key string would corrupt it — raw digest bytes aren't text. So createHmac takes an optional key encoding naming how the key string carries its bytes: omit it (or pass 'utf8') for an ordinary passphrase, or pass 'hex' / 'base64' / 'base64url' for key material that came out of an earlier digest().

const hmacHex = (key, keyEncoding, data) =>
    crypto.createHmac('sha256', key, keyEncoding).update(data).digest('hex');

// AWS Signature Version 4's signing-key ladder, as an example of the shape:
let key = hmacHex('AWS4{secret.AWS_SECRET_ACCESS_KEY}', '', '20260804');  // the secret expands here
key = hmacHex(key, 'hex', 'us-east-1');       // each step's hex digest is the next step's key
key = hmacHex(key, 'hex', 'ses');
key = hmacHex(key, 'hex', 'aws4_request');
const signature = hmacHex(key, 'hex', stringToSign);

Only the first step touches the credential, so {secret.KEY} expansion keeps it out of the script exactly as it does for a one-shot signature. An access key id or similar identifier that has to travel in a header can stay a secret too — put the placeholder in the fetch header, where it expands at the boundary. (The bundled aws-ses plugin signs every SES and SQS call this way.)

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 returns true only 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). Returns false when 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 reads order_id from 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/connectoauth/callback, storing a per-shop access_token with sw.secrets.set and calling sw.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 bridgeless payment.webhook_account parser returns the event's merchant_id (optionally merchant_id:location_id, so one Square merchant can back several shops — one per location); the platform resolves the linked shop and runs payment.webhook in it, where you read order_id from per-shop sw.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 hookvault 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:

  1. Signup. At checkout the platform calls create_intent with ctx.data.payment.save_method = true (or trial = true to defer the first charge). Your hook charges (unless trialing) and vaults the method, returning an opaque reusable token on ctx.data.payment.vault_token. The platform stores it securely as the customer's card on file and schedules the first renewal. (If the first charge settles asynchronously, return the token on the payment.webhook "paid" event instead — see above.) A customer has one card on file, so all of their subscriptions bill the same method; updating it (from the customer's account page, or by a new saved-card charge) applies to every one of their subscriptions.
  2. Renewal. A platform cron finds due subscriptions and, for each, builds a new order (the invoice) and calls create_intent again with ctx.data.payment.off_session = true and the customer's card-on-file token as vault_token. Your hook charges the vaulted method with no shopper present and returns status. A "paid" renewal flows through the normal paid path (fulfillment, digital delivery, checkout.after_payment); "pending" is reconciled later by your payment.webhook. Tax is recomputed live on every renewal against the shop's current rules and the subscription's shipping address — so the tax.calculate hook 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 — firing shipping.calculate + tax.calculate — when the customer changes the subscription's shipping address. checkout.before_create does not fire on renewals (the recurring price is deterministic).
  3. 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.
  4. 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 securely as the customer's card on file and only ever handed back to your create_intent on renewal (and on a saved-card checkout), so put whatever you need in it (e.g. JSON.stringify({ customer, payment_method }) for Stripe). You can also set the card on file directly with sw.customers.setPaymentMethod (see the Customers bridge) — e.g. from a storefront "manage payment method" page. 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. Receives ctx.data with query, cursor, limit, filters, facets, sort, price_min, price_max, in_stock, own_only, stream, count_accuracy. When in_stock is true, return only products currently available to purchase. Must set:

    • ctx.data.products — Array of {id, shop_id} references
    • ctx.data.cursor — Next page cursor. Empty string if last page — callers rely on "empty cursor == no more pages" to stop paging. The exception is stream: true requests, where you should always return the cursor as a resumable bookmark (those callers stop on an empty products page instead).
    • ctx.data.result_count — (Optional) Count of all matching products, shown as the storefront's "N results" label. count_accuracy is a hint for how exact this needs to be: count matches only up to that many (0 = the caller doesn't need an accurate total, so return an approximate one or skip it), and it never exceeds 10,000 — a caller never needs an exact count beyond that, so a backend can cap its own counting there and return an estimate above it.
    • ctx.data.facets — (Optional) Map of facet name → [{value, count}]
  • search.index: Index products (batch). Receives ctx.data.products as an array of full product objects. Called during normal saves and re-indexing.

  • search.remove: Remove products from the index. Receives ctx.data.product_ids as 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.result_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:

  1. Install the plugin
  2. Go to Settings > Search
  3. Click "Set Active" on your provider
  4. Click "Re-index All Products" to populate the external index

Template Render Hooks

  • template.before_render: Inject variables into Liquid templates before rendering.

require() works in render scripts. A script that handles template.before_render, a hook.* tag, a {% block %} wrapper or a filter.* can require('./lib/shared') like any other script — at the top of the file or lazily inside a handler. So one lib/ can back your routes, widgets, scheduled runs and render scripts alike.

Two things to keep in mind on the render path, since it runs for every storefront page view:

  • The first load of a module is slower than later ones, and a script that takes too long to load is dropped (see below). Keep a render script's module tree small, and prefer a lazy require() inside the handler that needs it over a top-level one that every page view pays for.
  • A throw while the script loads drops it from the whole render — its hooks, tags and filters never fire, and the page renders normally with your feature simply absent, which looks exactly like the plugin not being installed. So a require() of a path that doesn't exist (a typo, or a file you forgot to include in the package) fails silently in that specific way. Check it first when a storefront feature is invisible with no error anywhere.

Which bridges you get is a separate question from require(): template.before_render has the full sw.* surface, while hook tags, blocks and filters get only the small render-only subset described above.

Storefront breadcrumbs are data, not markup: every page with a trail carries a breadcrumbs binding — an ordered list of { label, url } entries, Home first, current page last — that the theme renders from one shared snippet. So a plugin changes a trail the way it changes any other binding, with no HTML to reproduce:

"template.before_render": function (ctx) {
    const trail = ctx.data.bindings.breadcrumbs;
    if (!trail) return;                                  // page has no trail (home, search)
    // Insert a collection crumb before the product on a PDP.
    if (ctx.data.bindings.dataloader === "product") {
        trail.splice(trail.length - 1, 0, { label: "Rugs", url: "/search?tag=rugs" });
        ctx.data.bindings.breadcrumbs = trail;
    }
},

A plugin page route sets its own trail by returning breadcrumbs alongside its other bindings — the bundled wishlist, returns, blog, and payment-method pages all do this:

return {
    template: "./templates/account/wishlist.liquid",
    bindings: {
        page_title: "Wishlist",
        breadcrumbs: [
            { label: "Home", url: "/" },
            { label: "Account", url: "/account" },
            { label: "Wishlist", url: "" }   // empty url = current page
        ],
        // …
    }
};

The template just includes the shared snippet — {% include 'snippets/breadcrumb' %} — so a plugin page inherits the active theme's breadcrumb styling instead of hardcoding its own. See Themes.md → "Breadcrumbs" for the built-in trails and the snippet's markup.

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_end is 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 only ctx.data.bindings; unlike template.before_render, they do not get ctx.data.template. To run only on a specific page, branch on ctx.data.bindings.dataloader. The order-confirmation page (/checkout/success, rendered as order.liquid) exposes the checkout-success dataloader and an order binding (totals in cents, items[], number/id) — this is how the bundled google-analytics plugin fires its client-side GA4 purchase event with no backend API key. Note the account-side order-detail / order-lookup pages also carry an order binding but use their own dataloaders, so keying off dataloader === "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 has price == 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 the checkout_pre_submit window event push a Promise to e.detail.promises and set your collected token on e.detail.meta (e.g. e.detail.meta.payment_token = ...). The platform forwards those meta[*] fields to your payment.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_render your 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; omitting order-detail is 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-input to self-select on the shared checkout_pre_submit event. The server never trusts this value — it charges via the shop's active gateway; the marker is purely a client-side signal.
    • Reusing / capturing a card on file. Like every render hook, checkout_payment receives the signed-in shopper as ctx.customer (absent for a guest — see the render-hook customer note). Use it to reuse or capture a saved method:
      • Pay with a saved method. When ctx.customer.payment_method is set (the non-secret hint { brand, last4, exp_month, exp_year }, absent when nothing is saved), offer a "pay with saved method" choice; when it's chosen, submit meta[use_card_on_file]=1 instead of a fresh payment_token and the platform charges the saved method for you (no create_intent token handling needed on your side).
      • Save this method. Gate a "save this payment method" checkbox (meta[save_card]=1) on ctx.customer being present, since a guest has nowhere to save it. When save_card (or a subscription line) is present, the platform vaults the entered method via your create_intent (save_method flag) and stores it as the customer's card on file automatically.
  • checkout_review — a general-purpose slot just above the Place Order button, for non-payment checkout additions (gift message, delivery date, PO number, …). Like checkout_payment it 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.

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.liquid, sw.assets, and sw.time. {% hook %} tag handlers (hook.*) run per-element on the render fast path (e.g. once per product card), so they are deliberately limited to sw.liquid (render a snippet), sw.assets (asset URLs), and sw.time (format a stored instant in the store's timezone — pure arithmetic, no lookups). The heavier bridges — sw.cache, sw.secrets, sw.products, sw.sql, sw.ledger, etc. (and the global fetch) — are not reachable inside a hook tag. Do any data-fetching in template.before_render (which has the full sw.* surface) and stage the result into ctx.data.bindings; the tag then reads it back from ctx.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.super to 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 includes super nests around the earlier ones.
  • Same sandbox as hook tags. block.* handlers get only sw.liquid, sw.assets, and sw.time. Load data in template.before_render (full sw.*) and read it from ctx.data.bindings. ctx.data.block is the block name; ctx.customer, ctx.settings, and ctx.plan are also provided.
  • Route/dataloader filtering applies exactly as for hook.* and template.before_render — declare routes/dataloaders/templates on the script to scope where the wrapper fires.

A typical use: wrap the default theme's {% block add_to_cart %} and — driven by one of your plugin's settings — append a Buy Now button, fully replace the Add to Cart button, or leave the block untouched by returning ctx.data.super. Make the Buy Now button 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_render only. 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 in before_render, which runs first with the full sw.* 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_render product blocks, related-products — aren't in before_render, so pair mutation with a block.price that renders the price when the binding wasn't pre-priced.
  • For live-updating or restructured prices, use block.price to wrap the theme's (mutated) markup with whatever the client needs. The bundled bullion-pricing plugin does exactly this: before_render mutates product.price to the live spot value; block.price wraps 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/redirect wins.

Catching the 404 (and other error pages)

The error page is a normal render, so template.before_render fires for it too — which is how a plugin redirects a dead URL somewhere useful (a retired product to its category, an old CMS path to its new home) instead of letting the visitor hit a 404.

Identify it by the error data loader, and read status_code from the bindings to tell 404 from 403/410/500:

// manifest.json: { "path": "redirects.js", "dataloaders": ["error"] }
module.exports = {
    "template.before_render": function (ctx) {
        if (ctx.data.bindings.status_code !== 404) return;

        const target = MAP[ctx.data.bindings.route];   // route = the path that missed
        if (target) {
            return { redirect: target, status_code: 301 };
        }
        // No match — fall through and let the error page render.
    }
};
  • ctx.data.bindings.dataloader === "error" marks the error page. Scope the script with dataloaders: ["error"] — don't reach for routes or templates: the route is whatever path missed (so there's nothing to declare), and the error template's filename is the merchant's to change.
  • ctx.data.bindings.route is the requested path, status_code the status about to be sent, page_title its default heading.
  • Besides the { redirect } / { body } short-circuits above, you can set ctx.data.bindings.status_code to change the status the error page is served with (e.g. 410 for something deliberately withdrawn) while still rendering the theme's error template. Adding bindings works as on any other page, so a "did you mean…" block can suggest products for the missed URL.
  • Prefer a 301 for a permanent move and a 302 for anything conditional; both are sent uncacheable, as with any short-circuit.

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

account_menu entries are flat. To contribute a top-level menu with its own submenu, append to settings.main_menu instead — its entries take an optional children array of the same { label, url } shape:

module.exports = {
    "template.before_render": function (ctx) {
        const menu = ctx.data.bindings.settings.main_menu || [];
        menu.push({
            label: "Guides",
            url: "/guides",
            children: [
                { label: "Sizing", url: "/guides/sizing" },
                { label: "Care", url: "/guides/care" }
            ]
        });
        ctx.data.bindings.settings.main_menu = menu;
    }
};

An entry with no children renders as a plain link. See Themes.md → "Menus with submenus" for every menu binding the header renders and how deep each one nests.

Email Hooks

Every transactional email the shop sends (order confirmations, shipping notices, password resets, sw.notify.customer messages, 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:

FieldMeaning
torecipient address
subjectsubject line
templatethe raw Liquid template string about to be rendered — replace it to swap the whole layout
bindingsthe variable map passed to the template (merge in your own keys)
template_namewhich email this is (order_confirmation, shipping_notification, password_reset, welcome, …)
notify_categorythe subscribe/unsubscribe topic this message belongs to (orders, shipping, plugin:acme:restock, …), or absent for uncategorized mail
notify_class"transactional" or "marketing" — the same value that decides whether email.marketing ran
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.marketing — replace the marketing channel

Fires first, before the store's layout is rendered, and only for messages in a marketing-class category (the store's own marketing topic, or a "class": "marketing" topic your plugin declared). It is the hand-off point for the whole marketing channel: you get the message — recipient, topic, subject, body, bindings — rather than the store-branded HTML, so you can pass it to a campaign provider in that provider's own shape, or wrap it in your own layout.

module.exports["email.marketing"] = function (ctx) {
    const d = ctx.data;   // { to, customer_id, category, category_label, class, subject, template_name, data, html, text }

    fetch("https://api.campaignprovider.com/v1/messages", {
        method: "POST",
        headers: { "Authorization": "Bearer {secret.CAMPAIGN_KEY}", "Content-Type": "application/json" },
        body: JSON.stringify({ to: d.to, subject: d.subject, template: d.template_name, vars: d.data })
    });

    ctx.stop("delivered via campaign provider");   // the store sends nothing itself
};
  • ctx.stop() hands you the message. Rendering, email.before_render, email.send and delivery are all skipped — and the message costs none of the store's marketing allowance (below). Without a stop() the message continues down the normal pipeline, with any changes you made to to, subject or data applied — so you can tag or retarget a campaign without taking delivery.
  • You get a body you can actually send. html (or text) is the message's own content — what the sender passed, or the sending plugin's own template already rendered. What is not applied yet is the store's email layout, which is the part a marketing plugin replaces. If you take the message, you own the whole email: wrap the content in your own layout and render the footer yourself.
  • Everything the footer needs is in data. It carries unsubscribe_url (the shopper's one-click link for this topic) and preferences_url, alongside the store projection (shop) and the sender's own bindings. A message you deliver must carry them — that link is how the shopper opted out of this topic, and it is the same door the store's own preferences read from.
  • Put unsubscribe_url in a List-Unsubscribe header too, not just the footer. Gmail and Yahoo require bulk senders to offer one-click unsubscribe, and mail without it is far likelier to be filtered as spam. The link accepts an unauthenticated POST, so it satisfies RFC 8058 as-is — set List-Unsubscribe: <url> and List-Unsubscribe-Post: List-Unsubscribe=One-Click. If your provider composes the message for you, pass the headers through its API; if you hand it a finished message, remember that a subject is caller-supplied text and must be stripped of newlines before it goes into a header. The bundled aws-ses plugin is a worked example.
  • A handler that throws also stands the platform down, rather than sending a message your handler may already have delivered. Use ctx.stop() for the deliberate case; a throw is a failure and is logged as one.
  • Transactional mail never reaches this hook. Order confirmations, shipping notices and account mail stay on the platform's own path, so taking over marketing can't accidentally take over receipts.

The store's own marketing allowance is small on purpose. The platform delivers a token number of marketing messages per month per plan (none at all on Free) — enough to try the feature, not to run a list. Everything above that is dropped, with one notification a day to the merchant. A store with a real list installs a marketing plugin: sends this hook claims go through the plugin's provider and its capacity, and are never metered by the platform. Transactional mail has no monthly budget at all — only a per-hour and per-day sending rate, set well above a busy day — and sign-in links and password resets aren't limited even by that.

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. It also carries the read-only notify_category / notify_class of the message, so a handler can treat marketing and transactional mail differently without re-deriving it. 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]) vs throw. 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). On email.before_render, ctx.stop() cancels the email entirely. Modifications you made to ctx.data still apply alongside a stop().

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 empty loc are dropped.
  • lastmod (optional) — last-modified date. Any value new Date() can parse is accepted; it's normalized to YYYY-MM-DD.
  • changefreq (optional) — always, hourly, daily, weekly, monthly, yearly, or never.
  • priority (optional) — 0.01.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…while cursor 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 declares depends, its dependencies are activated first, so by the time this hook runs every dependency's own plugin.activate has 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.data adds old_version alongside the standard plugin_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.
        // old_version may be several releases behind (skipped versions) — apply a
        // cumulative, idempotent migration ladder, not a single-step check. See
        // "Migrations must tolerate version skips" below.
        console.log("upgrading", ctx.data.old_version, "->", ctx.data.version);
        migrate(ctx.data.old_version, ctx.data.version);
    }
};

Migrations must tolerate version skips. The marketplace keeps only recent releases of a plugin (plus any version a shop still has installed), so older versions are periodically removed. A shop that hasn't updated in a while can therefore jump across several releases at onceold_version may be far behind version, and the releases in between may no longer exist. Never assume an upgrade steps through every version. Write your migration as a cumulative, idempotent ladder: run every step whose target falls in the range (old_version, version], in order, so a single jump 1.0.0 → 4.0.0 still applies the 2.x, 3.x, and 4.x steps. The same hook also fires on downgrades (old_version newer than version) — handle or explicitly skip that. And compare versions numerically: "10.0.0" < "9.0.0" is true as a plain string, so naïve < comparisons break past major 9.

// Ordered migration ladder — each step's version is when it must first apply.
const MIGRATIONS = [
    { version: "2.0.0", run: (db) => db.batch(V2_MIGRATIONS) },
    { version: "3.0.0", run: (db) => db.batch(V3_MIGRATIONS) },
    { version: "4.0.0", run: (db) => db.batch(V4_MIGRATIONS) },
];

// Numeric semver compare (returns <0, 0, >0). Plain string compare is wrong.
const cmp = (a, b) => {
    const pa = (a || "0").split(".").map(Number), pb = (b || "0").split(".").map(Number);
    for (let i = 0; i < 3; i++) {
        if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
    }
    return 0;
};

module.exports["plugin.change_version"] = function (ctx) {
    const { old_version, version } = ctx.data;
    if (cmp(version, old_version) < 0) {
        // Downgrade: don't replay forward migrations. Handle explicitly or no-op.
        console.log("downgrade", old_version, "->", version);
        return;
    }
    const db = sw.sql.connect("turso", "{secret.DB_DSN}");
    // Apply every step in (old_version, version], no matter how many were skipped.
    for (const m of MIGRATIONS) {
        if (cmp(m.version, old_version) > 0 && cmp(m.version, version) <= 0) {
            m.run(db);
        }
    }
};

Keep each step idempotent (CREATE TABLE IF NOT EXISTS, check-before-add), since a step may be retried after a failure or run against a fresh install.

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, call sw.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 }
    };
};

A route answers with body, not json. A string in body is served as HTML, anything else as JSON. Dashboard widgets use a different key (json / html) — return one of those from a route and the payload is dropped: the status you set still goes out, but with an empty body. It's a quiet one to spot, because a handler that catches an error and returns { status: 500, json: {...} } sends a 500 with nothing in it — check this key first whenever a response arrives empty.

Don't confuse route_path with the script-level routes array. A route_path (with type: "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, timezone, 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, and ctx.shop.timezone its IANA timezone (blank = UTC; see sw.time).

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 plugin route_path from theme JS — is rejected 403 unless it carries the CSRF token. Send the value of the (non-HttpOnly) csrf_token cookie either as an X-CSRF-Token header or a csrf_token form field; it must match the cookie. (This is the storefront scheme — distinct from the widget origin, which uses the X-CSRF header that window.sw.fetch attaches for you.) Read it client-side with document.cookie.split('; ').find(r => r.startsWith('csrf_token='))?.split('=')[1], or from a hidden input rendered by <csrf_tag />. A GET route needs no token — use GET for read-only endpoints (e.g. a price/availability poll) to avoid CSRF entirely. A route that must accept calls from outside the browser — a provider webhook, a server-to-server caller — declares "public": true instead (below).

Public routes (webhooks & machine callers)

A provider posting a webhook has no browser and no token, so a normal route would reject it. Add "public": true to the route's manifest entry and the token stops being required:

{
    "path": "webhook.js",
    "type": "route",
    "method": "POST",
    "route_path": "/webhooks/mailreach",
    "public": true
}

A public route is unauthenticated — authenticating the caller is your job. Verify the provider's signature (crypto.createHmac + crypto.timingSafeEqual) or a shared secret held in sw.secrets before you trust or act on anything in the body. Reject with a 401/400 when it doesn't check out.

A public route also never sees a signed-in shopper: ctx.customer_id is always absent, even when the request happens to carry a storefront session. That is deliberate — dropping the session is what makes the token opt-out safe, since a request reaching a public route may not have come from your storefront at all. So keep the split clean:

  • Shopper actions (add to wishlist, save a preference) → a normal route, CSRF token, ctx.customer_id.
  • External callers (webhooks, your own backend, a partner API) → public, your own signature or token check, and identify the account from the payload.

Everything else about a public route is unchanged: same fetch export, same ctx, same response shape, same time limits.

Cross-origin routes (CORS)

By default a route is same-origin only — a browser on another site can't read its response. Declare the origins allowed to call it with cors, as a single value or a list:

ValueMeaning
"*"Any origin.
"host"The store's own storefront origins — its domain(s) and its .shopswired.com address. The platform fills these in per store, so you don't hardcode a merchant's domain.
"https://app.example.com"Exactly that origin. Combine several in a list.
{
    "path": "api.js",
    "type": "route",
    "method": "POST",
    "route_path": "/ext/quote",
    "public": true,
    "cors": ["https://app.example.com", "https://staging.example.com"]
}

A browser on an origin that isn't allowed gets a 403 and the script never runs. Adding cors never narrows what already worked: a call from the store's own pages and a call from a server (which sends no origin at all, like a webhook) both go through as before. Browser preflight (OPTIONS) is answered by the platform from the declaration alone — you don't write an OPTIONS handler, and a preflight never executes your script or counts against your run budget. Whatever request headers the caller preflights are allowed through, so a custom auth or signature header works without extra setup.

cors is not access control. It tells a browser who may read the response; anything that isn't a browser can call the route regardless. If a route must only serve certain callers, check a secret or signature in the handler — on a public route that check is the only thing standing in front of it.

Cross-origin calls never carry credentials. A cookie-authenticated request can't be made cross-origin to a plugin route, so cors can't be used to reach a shopper's session from another site — pass whatever the route needs to identify the caller in the request itself (a signed token, an API key).

cors and public are independent: a same-origin-only public webhook needs no cors, and a cross-origin route that your theme calls with a CSRF token needs no public. Both appear in the plugin's capability list a merchant reviews before installing, so declare only what the route actually needs.

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 a string (UTF-8), an ArrayBuffer, a typed array (Uint8Array), or a readable handle (the .body of a fetch(...), sw.gdrive.download(...), or sw.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 of sw.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-Type in headers — it defaults to text/plain; charset=utf-8.
  • write wins. If write is a function it takes precedence; body and template on 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 return body instead.
  • 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.bg to build it and sw.files.upload(...) the result, then have the route return (or 302 to) that file once it exists — poll sw.files/a status flag on subsequent requests. A route that blows the budget is interrupted (see Memory & time limits below). Use GET for streaming endpoints (read paths; no CSRF token needed).

Page title & meta for a template route (page_title / page_description)

A route that returns a template renders inside the theme layout, so its browser <title> and <meta name="description"> come from the theme's SEO head. Control them by returning page_title and page_description in bindings; each falls back to the store default when omitted. This is how the bundled Blog plugin drives the blog list page's SEO from its settings, and each article page's from that post's own SEO fields.

return {
    template: "./templates/blog.liquid",
    bindings: {
        articles,
        page_title: "Blog",                        // browser tab + <title>
        page_description: "Latest news and guides"  // <meta name="description">
    }
};

Account section pages (account_active)

A route under /account/… can render as a section of the customer's account area — inside the same sidebar navigation the built-in Order History / Subscriptions / Profile / Addresses pages use — instead of a standalone full-width page. Opt in by returning an account_active binding naming your section:

module.exports.fetch = function (ctx) {
    return {
        template: "./templates/account/wishlist.liquid",
        bindings: { account_active: "wishlist", items }
    };
};

and wrapping your template's content in the account layout with the shared nav snippet:

<section class="container container-lg section">
    <div class="account-layout">
        {% include 'snippets/account-nav' %}
        <div class="account-content">
            <h2 class="fs-lg fw-600 mb-md">My Wishlist</h2>
            …your section content…
        </div>
    </div>
</section>

When account_active is present and the visitor is logged in, the platform injects the bindings the nav snippet needs (has_subscriptions, has_addresses) and marks the page noindex. Your sidebar link comes from account_menu (see "Contributing storefront menu entries" above) — the nav renders those entries after its built-in links and highlights the one whose url matches the current path. The bundled Wishlist and Returns plugins are worked examples.

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_token is never edge-cached (personalized pricing). Only anonymous visitors share the cached page.
  • GET only, and never in dev mode or theme preview (you always see fresh output while editing).
  • Streamed responses (write) are never cached regardless of cache.

You own correctness-of-variance. cache is 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 set cache — the edge would serve one visitor's page to the next. Prefer setting cache from 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 values the platform resolves itself, never ones the visitor supplied:

HeaderMeaning
X-Real-IpVisitor IP (always present; read from the connection itself, not from anything the client sent).
X-Geo-CountryISO country code, e.g. US.
X-Geo-RegionRegion/state name.
X-Geo-CityCity name.
X-Geo-Latlong"lat,long" (single header), e.g. "37.77,-122.42".
X-Geo-PostalPostal/ZIP code.
X-Bot-ScoreBot-detection score 199 (low ⇒ likely bot).
X-Verified-Bot"1" for a known-good crawler (Googlebot, etc.), else "0".
User-AgentThe 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 resolves these itself on every request and strips any client-supplied X-Geo-* / X-Bot-* / X-Real-Ip copies before re-injecting its own — so a visitor cannot forge them. A value the platform could not resolve for itself is never passed on as if it had: you get the header with a value the platform stands behind, or you get no header. There is no third state in which a shopper picks their own country or address.
  • X-Real-Ip is always present. Geo and bot headers are only present when the platform could determine them — treat a missing header as "unknown" and fail open. X-Bot-Score / X-Verified-Bot additionally depend on bot detection being available for the store; without it they're absent.
  • Varying a cached page by geo. Storefront pages are stored and reused, so a template.before_render hook that changes what a page shows based on X-Geo-* will show one visitor's result to the next. Either keep the variation client-side, or have the merchant enable Pages that change by country (Settings → Developer), which makes the visitor's country part of the storage key. It splits by country only — a page varying on city, postal code or coordinates still needs the client-side approach. Marking the response uncacheable also works and is the right call for a page that is genuinely per-visitor.
  • checkout.before_create now carries ctx.request, so a checkout blocker can decide on IP/country/bot in addition to ctx.data.order email/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:

  • fetch route handlers: Receive ctx.customer_id (the ID of the logged-in customer, or 0 if guest). Because fetch handlers run with full access to the Bridge API, you can query the full customer record if needed via sw.customers.get(ctx.customer_id).
  • Render Hooks (template.before_render, hook.*) and storefront cart→checkout hooks (cart.calculate_prices, coupon.validate, shipping.calculate, tax.calculate, checkout.before_create): Receive the full customer object as ctx.customer (e.g., ctx.customer.id, ctx.customer.email, ctx.customer.price_level, ctx.customer.payment_method, ctx.customer.payment_gateway) when the shopper is logged in — ctx.customer is absent for guests (guard with if (ctx.customer) { … }). It's a read-only snapshot; secret fields are never exposed. This lets a pricing/coupon/shipping hook vary its result by the signed-in shopper (B2B tier, saved-method gateway, etc.) without a separate sw.customers.get lookup.

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:

  1. A scripts[] entry with type: "widget" mapping the script to a widget id.
  2. 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), or "link" for an entry that only opens a URL (see Linking to a page instead of opening a widget).
  • source.script — for internal widgets, the path that matches the scripts[].path declared above.
  • permissions — display-only string list shown in the gallery (e.g. "Reads: products, orders"). Enforcement happens via the existing per-plugin sw.* scoping; this field is documentation for the shop admin.
  • config_defs — per-instance config UI schema, same shape as settings[] (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 via placement.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-keyed permissions map (see Gating custom records with permissions); widget/page roles remain role-name based for now.
  • styles"" (default) applies the bundled classless sw-widget stylesheet 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", "mode": "menu", "label": "Resend invoice" }
    ]
}
  • 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; "menu" adds a row to the page's ⋮ (more actions) menu — same modal, lower emphasis, for actions that don't deserve a permanent button (it's also where a link placement belongs); "field" (custom record types only) marks the widget as a record-field editor — it never appears as a tab, button or menu row, and is instead embedded by a custom_records field that names it via "type": "widget" (see Record-field widgets).
  • label — tab title / button or menu text (defaults to the widget name).
  • icon — optional leading glyph (tab, button or menu row).
  • variant (button/menu)"primary", "secondary" (default), or "danger".
  • size (button/menu) — modal size "sm", "md" (default), or "lg".
  • url (button/menu) — makes the entry a link instead of a widget launch; see below.
  • roles — optional per-placement role gate (role-name → level), applied in addition to the widget-level roles.
  • condition — optional "<setting_key> == <value>" expression (same syntax as a settings field's condition). The placement only appears — and can only be launched — when the plugin's effective settings satisfy it, so it 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 bundled stripe-payment plugin uses this to show a Risk tab on the order page only when the merchant turns on its enable_radar setting.

Tabs are lazy — a tab is only rendered the first time it's opened. A button or menu row costs nothing until clicked.

Linking to a page instead of opening a widget (url)

A menu (or button) placement that sets url doesn't run your widget at all: it renders as a link that opens the target in a new tab. This is how a record gets a "View on Storefront" action — the bundled blog plugin points its post records at their published page:

{
    "entity": "custom:blog",
    "mode": "menu",
    "label": "View on Storefront",
    "url": "{settings.path_prefix}/{record.slug}"
}

The url may be a path (resolved against the store's storefront address, as above) or a full https://… address for an external tool. Four tokens are filled in for you:

TokenValue
{storefront_url}The store's storefront address, no trailing slash (only needed when the link isn't a plain path).
{settings.<key>}One of your plugin's settings — so a merchant-configurable path like path_prefix stays correct.
{id}The id of the record on screen.
{record.<field>}A field of the record on screen ({record.slug}, {record.email}, …). Values are URL-encoded.

Only http/https targets are ever rendered, and a row is hidden rather than pointing somewhere wrong when a token has no value on this record (a post with no slug yet) or a {settings.…} key doesn't exist.

A link placement needs no script — declare the widget with "source": { "type": "link" } and keep it out of the dashboard gallery:

"widgets": [
    {
        "id": "view-post",
        "name": "View on Storefront",
        "source": { "type": "link" },
        "placement": {
            "dashboard": false,
            "detail": [
                { "entity": "custom:blog", "mode": "menu", "label": "View on Storefront", "url": "{settings.path_prefix}/{record.slug}" }
            ]
        }
    }
]

Closing a button modal and refreshing the page. A button or menu 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.

Record-field widgets (mode: "field")

A custom_records field declared as {"name": "items", "type": "widget", "widget": "<widget_id>", "label": "Items"} is rendered on the record edit page by your widget instead of the built-in JSON editor — a purpose-built view/editor for structured data (line items, schedules, matrices). Wire it up in three parts:

  1. The field names one of your own widgets via widget.
  2. That widget declares the placement {"entity": "custom:<type>", "mode": "field"} — this authorizes it to bind to records of that type; field-mode placements never appear as tabs or buttons.
  3. The widget's fetch(ctx) reads the record itself (sw.records.<type>.get(ctx.widget.entity.id)) and renders the field's current value.

Editing goes through sw.field.set(value) (client-side, from the widget's markup/JS): it pushes the edited value into the record form's unsaved state — the merchant still clicks the form's own Save to persist, exactly like every other field. Call it on each change (or on a "Done" action); calling it repeatedly just replaces the pending value. It is a no-op outside a field placement. The value is stored like a json field: any structure, not filterable.

The widget knows what it's editing. A field render receives, on top of the usual widget context: ctx.widget.placement === "field", ctx.widget.entity ({type: "custom:<type>", id}), ctx.widget.field (the field name from the record's schema) and ctx.widget.id (the widget id being rendered). The same trio is mirrored client-side as sw.entity, sw.field.name and the widget id you already know. So one script can be reused broadly:

  • One widget id, many fields/record types — branch on ctx.widget.field / ctx.widget.entity.type. Declare one mode: "field" placement per record type it serves; each field that names the widget binds it (the platform verifies a launch's field is actually declared with "type": "widget", "widget": "<this id>" on that record type, so a widget can't be attached to arbitrary fields).
  • One script file, many widget ids — declare several widgets[] entries whose source.script (and matching scripts[] entries) point at the same file, then branch on ctx.widget.id. Prefer this when two fields of the same record use the same editor, so each iframe keeps its own session.

Notes:

  • On a new (unsaved) record there is no id to bind, so the form shows a placeholder until the record is first saved; design the field to tolerate an empty value.
  • Read-only viewers see the widget too — the form simply has no Save for them, so pending sw.field.set values go nowhere. You can also branch on ctx.widget.user.permissions to hide edit controls.

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 for sw.storage keys 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.pagetrue when rendering as a left-menu page (no dashboard membership). Per-instance config falls back to defaults.config since there's no dashboard to read from.
  • ctx.widget.entity — for detail-page widgets, the record the widget is bound to: { type, id } where type is "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", "button", or "menu". Lets one widget adapt its UI to context — e.g. show a Close button (sw.close()) only when it's in a modal ("button" / "menu"), 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_url is 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 under ctx.widget.user.role.
  • ctx.permissions — array of this plugin's declared permissions the viewer holds (de-namespaced). Also under ctx.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 allows connect-src 'self', so any in-iframe AJAX must hit this prefix — the bundled window.sw.fetch helper does this for you (see Built-in styles and helpers below).
  • ctx.widget.url(path) — same as base but as a function for scripts that prefer a call: sw.fetch(ctx.widget.url('refresh')). Equivalent to ctx.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 applies class="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 grid repeat(auto-fit, minmax(120px, 1fr)) with gap: 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-widget so 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 in manifest.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 small window.sw.* helper API and smooths over the iframe sandbox so ordinary markup behaves natively.

    sw.fetch(path, opts) — the core helper:

    • Prepends ctx.widget.base to any non-absolute path.
    • On POST/PUT/PATCH/DELETE, attaches X-CSRF: ctx.widget.csrf.
    • If opts.body is a plain object (not FormData/URLSearchParams/Blob/ArrayBuffer/string), serialises it as JSON and sets Content-Type: application/json.
    • Returns the Promise<Response>; callers do .json() / .text() / .ok themselves.
    <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.base and window.sw.csrf are also exposed for non-fetch use cases.

    Sandbox smoothing. The iframe is sandboxed allow-scripts allow-same-origin — deliberately without allow-forms or allow-modals — so native <form> submission and alert()/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 cancelable submit event fires, so it can't be intercepted from the page. Give every button type="button" and POST with the sw-post directive (which serializes the nearest <form>) or call sw.fetch yourself. Put onsubmit="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: ok default, error, warn). window.alert is shimmed to call it.

    • await sw.confirm(message, opts) resolves true/false from an in-DOM dialog (opts: okText, cancelText, danger) — the async replacement for the blocked confirm().

    • 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 (or string[] when multiple), or null if cancelled. root optionally restricts the browser (public, private, …).
      • sw.pickRecord({ model, multiple }) resolves to { id } (or { ids: [...] } when multiple), or null. model is product, customer, order, coupon, or custom:<type> (defaults to product).
      • 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", or null if cancelled — directly usable as a sw.files prefix (sw.files.upload(folder + "/report.csv", data)). root optionally 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. value seeds it with an existing layout (the same { version, layout: [...] } object the builder produces). themeId/field pick the theme its preview renders against (default: the shop's active theme). Resolves to the edited layout object, or null if cancelled. Store the result yourself (e.g. sw.storage or your own record) and render it on the storefront with the layout_render Liquid 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 without allow-popups/allow-top-navigation, so target="_blank" and window.open are 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 a mailto:/tel:/ sms: scheme is handed to the host, which opens it in a new tab (with the opener severed). Plain same-origin links with no target still navigate the iframe in place as usual — so internal widget navigation is unaffected. Add data-sw-no-open to 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). url may be relative (resolved against the widget) or absolute. The host only opens http:/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 tab
      

      Navigating the admin in place — opts.target: '_self'. Pass an admin path together with { target: '_self' } and the host navigates the admin in the same tab instead of opening a new one — e.g. a dashboard-card KPI that jumps to the matching screen in your page widget (build the path with sw.widget.url(widgetId) so it isn't hardcoded). This works for any admin path on the same origin; an off-site URL isn't an in-place navigation and still opens in a new tab.

      // From a dashboard card: open this plugin's "inventory" page in place.
      sw.open(sw.widget.url('inventory'), { target: '_self' });
      
    • sw.close(opts?) and sw.entity — for detail-page widgets. sw.entity is the bound record { type, id } (or null elsewhere). sw.close() dismisses the host modal a mode:"button" widget renders in and, by default, asks the host to re-fetch the underlying record; pass sw.close({ refresh: false }) to skip the reload. It's a no-op for tab/page/dashboard widgets (a tab can still call sw.close({ refresh: true }) to request a page reload after it mutates the record).

    • Native alert/confirm/prompt are overridden to a safe default (so they stop logging sandbox errors) and console.warn once pointing at the sw.* equivalents. confirm()/prompt() can't block synchronously without allow-modals, so always use the async sw.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 fixed toast 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.

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:

DirectiveEffect
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):

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

Two optional response headers let a swap drive UI a fragment can't reach on its own (inline <script> in swapped HTML doesn't run):

Response headerEffect
X-SW-Toast: SavedShows a success toast after the swap.
X-SW-Title: Inventory · ProductsSets the browser-tab title. On a page widget this is how a multi-view app keeps the tab in sync as the user switches views — see Page title.

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

Page title (automatic). A page widget's admin browser tab defaults to the widget's own name — the tab reads <widget name> - Admin - Shopswired, no work required. To make it dynamic, set document.title in your widget and the tab follows it. On a first (full) load an inline <script>document.title = '…'</script> works; after a fragment swap an inline script won't run, so return an X-SW-Title response header instead (see Fragment swaps above) — either way the tab updates and stays put as the user navigates between your views. The title is treated as plain text (never rendered as markup) and trimmed to a reasonable length. Only full-page widgets drive the tab title — dashboard cards and detail-page widgets don't.

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 fetch back 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 via sw.fetch). Inline scripts and eval are 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 refresh from 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-changed from the parent. By default, a config change triggers a full iframe reload (the new config is picked up on the next fetch(ctx)). To handle config changes without a reload, define window.__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 to sw.files.upload / sw.csv.reader with no buffering and no size cap.
  • .text() / .json() / .bytes() — buffer the whole body into memory (capped at 1 MB for fetch; stream via .body for 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.)

fetch reaches the public internet only. Requests to internal, private, or loopback addresses are refused — on the initial request and on every redirect hop — and redirects are followed only for http/https URLs.

Form posts. As in the browser, the body's type picks the encoding:

  • FormDatamultipart/form-data (always, even with no files). append(name, value, filename?) takes a string (a field) or a .body stream / bytes (a file). File parts are streamed (never buffered), the Content-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 });
    
  • URLSearchParamsapplication/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.

Page and batch limits

Every paginated sw.* call is capped, so no single call can pull an unbounded result set:

CapDefault when limit is omitted
list / history (sw.products, sw.orders, sw.customers, sw.coupons)500 rows100
sw.records.list, sw.storage.list, sw.ledger.list / .history500 rows50
Batch get / save / delete (array form)500 ids/records

Ask for more than the cap and the call throws — it never quietly returns a short page, which would look like "that's all the data" and silently corrupt a total or a sync. To walk a larger set, page with the returned cursor:

let cursor = "";
do {
    const page = sw.products.list({ filters: { active: true }, limit: 500, cursor });
    for (const p of page.items) { /* … */ }
    cursor = page.cursor || "";
} while (cursor);

limit: 0 (or a negative one) means "unspecified" and uses the default page size above — it is not a way to ask for everything. Watch for a limit computed from a missing value ({ limit: opts.count } where count is undefined): that quietly becomes a default-sized page, not the full set.

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

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 and faceted filters.

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
    sort: "relevance",              // optional order; see below
    stream: false                   // optional; see cursor semantics below
});

res.items;        // → array of products (priced for the storefront)
res.cursor;       // → cursor for the next page (absent on the last page)
res.facets;       // → { color: [{ value: "blue", count: 12 }, …], brand: [...] }
res.facet_labels; // → { color: "Color", … } display labels, when the field defines one
  • Cursor semantics match list (and every other paginated sw.* API): by default an empty/absent res.cursor means there are no more pages, so do { … } while (res.cursor) is the standard walk-everything loop. Pass stream: true to always get a cursor back — a resumable bookmark you can carry across runs (e.g. a background task that periodically re-checks for results past where it left off). In stream mode the cursor never goes empty, so stop when res.items comes back empty instead.
  • filters here are exact equality on indexed facet fields and take string values (unlike list's range operators). Use them to drill down within a search.
  • facets asks the index to return value→count buckets for those fields so you can render a faceted sidebar; omit it if you only need results.
  • sort orders the results. Omit it (or "relevance") for the default relevance ranking; other keys: "newest" / "oldest" (by created date), "price_asc" / "price_desc", "name_asc" / "name_desc", and "recently_updated" (most-recently modified first — handy with limit: 1 to read the newest change timestamp for cheap catalog-change detection).

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.
//
// status is derived from the order's facts (payment.status + shipped_at /
// cancelled_at); writing it performs the matching action — "shipped" records the
// shipment (stamping shipped_at), "cancelled" records the cancellation, the money
// statuses update payment.status. A write the facts contradict (say,
// "payment_failed" on a captured payment) is a no-op: the saved order comes back
// with the truthful status, so read it from the result rather than assuming the
// write stuck. See Entities.md → Order for the full table.
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);

Card on file. A customer can have one saved payment method (their "card on file"), used to bill their subscriptions, to charge an order from the admin, and for one-click checkout. A payment plugin manages it:

// Store (or replace) the card on file after vaulting a method with your gateway.
sw.customers.setPaymentMethod(customerId, {
    gateway: "stripe",                 // the gateway id that vaulted the method
    token: JSON.stringify({ ... }),    // your gateway's reusable token (opaque to the platform)
    brand: "visa", last4: "4242", exp_month: 12, exp_year: 2027  // card display hint (optional)
    // …or, for a non-card method, a label instead of the card fields:
    // label: "Cash App Pay"
});
sw.customers.clearPaymentMethod(customerId);   // remove the saved method

clearPaymentMethod throws if the customer still has a subscription billing that method (active, trialing, or past-due) — removing it would silently break the next renewal, so the customer must cancel the subscription first. Surface the thrown message to the shopper. A checkout that includes a subscription line always saves the entered method as the card on file (the shopper needn't tick "save my payment method"), so a subscriber always has a method on file for renewals.

token is opaque — the platform stores it securely, never inspects it, and hands it straight back to your gateway's payment.create_intent on renewals and saved-card charges. Only the display hint is readable back, via sw.customers.get(id).payment_method ({ brand, last4, exp_month, exp_year } for a card, or { label } for a non-card method); the token itself is never returned. See Entities.md for the field shape.

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.

Subscriptions (sw.subscriptions)

Recurring contracts. Reading is always available; changing one requires your manifest to declare the capability, because it re-aims a recurring charge at a customer's saved payment method:

{ "subscriptions": true }

Merchants see that declaration on the app's listing before they install.

// Reading — the same list/get every built-in record has.
const { items } = sw.subscriptions.list({ filters: { customer_id: 42, status: "active" } });
const sub = sw.subscriptions.get(items[0].id);

// Changing the contract. Send only what you're changing.
sw.subscriptions.update(sub.id, {
    items: [{ ...sub.items[0], qty: 5 }],   // 5 lessons this term
    next_bill_at: "2026-10-01T09:00:00Z",   // a Date works too
});

sw.subscriptions.pause(sub.id);
sw.subscriptions.resume(sub.id);
sw.subscriptions.cancel(sub.id);

update accepts interval, anchor, anchor_value, next_bill_at, max_cycles, items, and shippingevery field is optional, and one you omit is left alone. Patch rather than echo the whole record back: a renewal may have advanced the contract since you read it, and resending stale fields would undo that.

Some deliberate limits, all of which throw with a message worth surfacing:

  • There is no save or delete. A subscription owns a billing schedule and a set of totals that must agree with its lines; writing it wholesale would let those drift apart. Use the named actions.
  • You supply lines, not totals. Tax and shipping are recalculated from the items and the address on every change (your tax.calculate and shipping.calculate hooks run), so a totals key is ignored.
  • next_bill_at must be in the future. Billing early is its own decision, not a side effect of editing a date.
  • A cancelled contract can't be changed, and a paused one can't be rescheduled until it resumes.
  • Anchors must match the cadence — only a weekly contract can be pinned to a weekday, only a monthly-or-longer one to a day of the month. See Entities.md for anchor values and the month-end rule.

Every change is recorded in the subscription's history with your plugin named as the author, so a merchant can always see what your app did and when.

For an amount that changes every cycle rather than a permanent change to the contract, use the subscription.before_renew hook instead — update rewrites what the contract says forever, which is the wrong tool for "this month had three lessons".

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(). One exception: on products, orders, customers, and coupons, a meta key that starts with _ also derives an entry on the record's read-only index array, which is filterable — so a plugin can stamp a marker and later find its records by it (see Filter operators). So:

  • Reach for .meta when 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.

.meta is not a private store: a theme can read it in Liquid wherever the record is in scope (product.meta, order.meta, customer.meta, subscription.meta) — nothing renders it by default, but it is reachable. Keep credentials and anything a shopper must not see in secrets, not in .meta.

.meta mutated 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), call sw.orders.save(...) / sw.customers.save(...) etc. to persist it.

.meta merges by key on save. Keys you don't send survive, so you can stamp one key without loading and resending the rest — sw.products.save({ id, meta: { _extid: "123" } }) leaves every other plugin's keys intact. The flip side is that deleting a key locally and saving the record back does nothing — the stored key survives the merge:

const p = sw.products.get(id);
delete p.meta.stale;
sw.products.save(p);                              // ✗ `stale` is still stored
sw.products.save({ id, meta: { stale: null } });  // ✓ set it to null instead

A key you send is replaced whole — nested objects are not deep-merged — and meta: null clears the map. See Entities.md → Saving with save() for the full table.

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.

Nested fields use a dotted key. On orders, the customer snapshot is filterable that way — customer.email (stored lowercase), customer.name (exact, case-sensitive, as entered), and customer.phone (as entered):

sw.orders.list({ filters: { "customer.email": "[email protected]" } });

Attaching plugin data (.meta) and querying it (index). meta is a free-form, plugin-owned map on products, orders, customers, and coupons — but it's opaque and can't be filtered. To make a value queryable, name its key with a leading _: it derives an entry on that record's read-only index array, namespaced under meta#meta._rinven_id = "123""meta#rinven_id#123" (see Entities.md). This is the standard way to attach your own queryable field to a built-in record — e.g. an external sync stamps its foreign id and later resolves the record by it, decoupled from the SKU/code/email:

// Stamp on save (nothing renders meta by default, so the marker stays off the page)
sw.products.save({ id, meta: { _rinven: "1", _rinven_id: "123" } });

// Enumerate everything this plugin owns; resolve one product by its source id
sw.products.list({ filters: { index: "meta#rinven#1" } });          // equality — page with cursor
sw.products.list({ filters: { index: "meta#rinven_id#123" }, limit: 1 });

// Same field, same rules, on the other built-ins:
sw.orders.list({ filters: { index: "meta#extid#SO-4471" }, limit: 1 });
sw.customers.list({ filters: { index: "meta#crm#c_88" }, limit: 1 });
sw.coupons.list({ filters: { index: "meta#promo#spring" } });

// A customer's `fields` derives entries the same way, under "fields#". Reach for
// it instead of `meta` when the merchant should SEE the key on the customer's
// admin page (an imported account number, the source id of a synced record):
sw.customers.save({ email: "[email protected]", fields: { _crm_id: "c_88", company: "Acme" } });
sw.customers.list({ filters: { index: "fields#crm_id#c_88" }, limit: 1 });

Scalars, booleans, and scalar arrays derive entries (arrays expand to one each); nested objects and blanks are skipped; entries are deduped and sorted.

index is equality-only — filter it with an exact value (index: "meta#rinven#1"), not a range or a * prefix. Because index holds multiple values per record, a range/prefix filter over-matches (a record with one entry below your prefix and another above it comes back even with nothing in the prefix — the standard behavior for an inequality over a multi-valued field). So model your marker as the exact value you'll look up: a fixed membership marker (meta._rinven = "1" → match "meta#rinven#1") and a full source key (meta._rinven_id = "123" → match "meta#rinven_id#123"). Query index on its own (equality, no order) so it resolves on the built-in single-property index with no composite. (The * prefix glob is a single-valued-field feature of sw.records.<type>.list and sw.search — it does not apply to index.)

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 fieldorder names 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 A plus order on B (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 (order then defaults to it, and passing a different order throws). 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, and richtext/textarea fields (these hold large bodies and default to unindexed — set "index": true on 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: linkAccount throws if the account is already linked to a different shop (re-linking by the owning shop is a no-op), and unlinkAccount only 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 via sw.secrets.get.
  • help: (Optional) Guidance shown as the field's tooltip in the panel.
  • readable: (Optional) Pre-fills the entry as readable (so sw.secrets.get can 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; sets exp). iat is stamped automatically. For a fixed expiry set claims.exp yourself and omit expiresIn.
  • 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's alg header 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:

FieldMeaning
allowedfalse once more than limit hits land in the window — gate the action on this.
remainingHits left in the current window (0 when blocked).
reset_atUnix 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 windowSeconds bucket share one reset_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):

ClassCallsCost
Write*.save, storage.set, ledger.credit/debit/compact, files.upload3 units × item count
Delete*.delete, storage.delete, files.delete1 unit × item count
Read*.get (× item count); files.read/download, storage.get1 unit
Query*.list, *.getBySlug, products.search, ledger.balance/history/list/sum, files.list5 units
Freesw.cache, sw.time, crypto, sw.jwt, sw.csv, sw.excel, 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:

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

    PlanBurstSustained (units/sec)
    Free3,000200
    Pro8,000500
    Business20,0001,000
    Enterprise30,0002,000
  2. 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 (or sw.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 & time limits

Every run has a time budget and a memory budget, both sized by where the code runs. A run that goes over either one is stopped mid-execution, and the reason — naming the call and the limit — is written to the plugin's log, so a stopped run always tells you what to change.

Where your code runsTimeData it may hold at once
Storefront page render — template.before_render (the data-loading hook)~2s16 MB
Storefront page render — every other hook, filter and {% hook %} tag~1s16 MB
Storefront page render — loading your script (code outside your hooks)~0.5s16 MB
Event hook (order.created, cart, search, …)~5s32 MB
checkout.* hook~10s32 MB
payment.* hook (the call to your provider)~20s32 MB
Fetch route, dashboard widget, "Run" test~30s64 MB
Background task (sw.task.bg, scheduled)task budget128 MB

Stores on the higher plans get double the memory figures.

On a page render the two columns are counted differently. The time is per call — every hook, filter and tag gets its own second — while the memory is for the whole page, shared by every plugin rendering it. So the budget that usually bites first on a page is time, and it bites per call.

Loading your script is its own step, and its budget is deliberately small. A page render walks your script twice: once to run the file — everything outside your exported functions, which is how the platform learns what you export — and again to call the hook. Loading gets half a second, which is ample for require()s, function definitions and constants, and nowhere near enough to fetch, query or build anything. That is the point: do no work at the top level.

Two reasons it matters more than it looks. Top-level code runs on every page render, so it is not a cache — build lookup tables in a background task and read the stored result, or every page view pays for it. And a script that fails to load is dropped whole: none of its hooks, filters or tags are registered, so nothing it declares ever fires. Both a timeout and a throw while loading are written to your plugin's log saying exactly that.

Load data in template.before_render, and only there. It is the one render hook with the full bridge surface, and it gets twice the budget of everything else on the page for exactly that reason. Two seconds is still not much: a single network round trip can spend most of it, so treat before_render as the place to read data you already have (sw.cache, sw.storage, a record you wrote earlier) and do the fetching in a background task. Filters and tags run many times per page — keep them to formatting what before_render already loaded.

"Held at once" is not "moved through". The memory budget counts platform data your script is holding — a body you buffered with .text(), a file you read with sw.files.read, a page of records or rows you asked for. Data that streams through your script is not held: it moves a chunk at a time and each chunk is released as the next one arrives. That is why a pass-through has no practical size limit at all:

// ✅ streams — holds one chunk, whatever the file's size
sw.files.upload("copy.csv", sw.files.download("orig.csv").body);
sw.files.upload("export.csv", (sink) => { for (const row of rows) sink.write(row); });

// ❌ holds the whole thing — counts against the budget in full
const all = sw.files.download("orig.csv").text();

The same applies to reading data: a cursor holds one page at a time, so paging through a million rows stays flat.

// ✅ one page held at a time
let cursor = "";
do {
    const page = sw.records.list("invoice", { limit: 500, cursor });
    page.items.forEach(process);
    cursor = page.cursor;
} while (cursor);

What the budget does not count is what you keep yourself. It covers data the platform is holding for you — the body you buffered, the page you asked for. Copy that data into your own array or string and it becomes yours: no limit is watching it, and nothing will warn you.

// ⚠️ allowed, unreported, and still the thing that will sink your plugin
const everything = [];
let c = "";
do { const p = sw.records.list("invoice", { limit: 500, cursor: c }); everything.push(...p.items); c = p.cursor; } while (c);

A run like that usually ends up stopped anyway — it runs out of time long before it runs out of rows — but the reason it reports will be the time budget, not the pile of rows that was the real problem. Process each page and let it go.

Loops must finish. A loop that never terminates is stopped, whether or not it allocates anything. This is measured by work done, not by clock time, so the same script behaves the same way on a quiet store and a busy one.

When a store is short of memory, the run holding the most data may be stopped so the rest of the store keeps serving. Holding less at any one moment is what keeps your plugin out of that position.

When a store is at capacity, your run waits its turn rather than being skipped — a busy moment costs latency, not correctness. But the wait is not unlimited: if the store is still full when a request runs out of time to wait, that run is skipped and an error is written to your plugin's log naming the script and the hook that did not run.

Treat that log line as a real failure. On most stores nothing else will tell you: the request finished, the page was served, and your side effect simply never happened. A store can also choose to fail the request instead when this happens, so on those stores the same situation surfaces as an error to the shopper. Either way it means the store is spending more time in plugin work than it has — move the slow part to a background task.

checkout.after_payment is never skipped either — but it may arrive late. Payment has already been taken by the time it fires, so the store will not fail the order over it. If the store has no capacity to run it at that moment, it is queued and runs shortly afterwards instead of being dropped, and a queued run can be retried — so write this handler to be safe if it runs twice. Keying off the order id is usually enough: check whether you already pushed that order before pushing it again. Everything else about the hook is unchanged; a late run sees the same data an immediate one would.

Hooks that price an order are never skipped. While a checkout is being assembled — checkout.before_create, tax.calculate, shipping.calculate, coupon.validate — your hook waits far longer than it would on a page, and if it still cannot run the checkout stops with an error rather than charging the shopper for an order priced without it. So you can rely on a pricing hook actually running: if your plugin is the one that adds tax, a busy store will never quietly place an untaxed order. The flip side is that a plugin which is slow here stops the store taking orders, so keep this path fast and do the heavy work elsewhere. This applies only before any payment is taken; a hook that runs after the money moves is never allowed to fail the order.

Repeated stops pause the script. A single stop is a bug to fix. Five stops within an hour temporarily pause that script on the request path — background tasks keep running, since that is where heavy work belongs — and the pause lifts by itself. Publishing a fixed version clears it immediately: the pause applies to the exact code that overran, so new code is never affected. Marketplace authors can see every stop, with the numbers measured during it, in the Health tab of their published item.

Practical takeaway: stream large data instead of buffering it, page instead of collecting, 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 upload throws) if the callback throws, so a failure never persists a partial file as a success.
  • Pair it with sw.csv.writer to 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 straight to the file, so a multi-GB file never flows through your route) 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.
  • path is resolved against your shop's files exactly like upload/read, so you can only sign your own files.

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) => {
    const p = sw.products.get(ctx.args);
    // ... 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, use sw.task.run + sw.task.join instead.
  • 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 and read them back on ctx.args. Most closures instead require() their dependencies inside.
  • Full bridge access: each task runs as its own isolated run with all sw bridges, and may itself enqueue more sw.task.bg work.
  • 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. Use ctx.timeoutRemaining() rather than assuming 10 minutes.)
  • Options object (opts, the optional second argument — consistent with sw.task.continue(data, opts)):
    • args: array exposed to the closure as ctx.args.
    • 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.bg throws 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 prefer sw.task.continue() (which keeps one slot) over recursive sw.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 single ctx object. Whatever you pass as opts.args is handed back verbatim on ctx.args — an object, an array, or a bare value — as an independent deep copy (the task runs later, in its own run, so mutating ctx.args affects nothing else, and the value must be plain JSON-serializable data: no functions or class instances). It is undefined when you enqueue no payload. Because it's passed straight through, a named object is usually the clearest shape — { args: { orderId, sku } } read as ctx.args.orderId — but a bare value (ctx.args) or an array (ctx.args[0]) works just as well. The ctx also 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) => {
            const order = sw.orders.get(ctx.args.orderId);
            fetch("https://erp.example.com/sync", {
                method: "POST",
                body: JSON.stringify(order)
            });
        }, { args: { orderId } });
    }
};

Recovering failed and unpaid orders

Checkout is payment-first: an order exists once the shopper has committed to pay, and orders whose payment then didn't complete are never swept or deleted. A failure lands the order in payment_failed; an order still awaiting payment (offline store, admin invoice, unsettled renewal) stays created. They're left in place on purpose so a plugin can run retry nudges, dunning, or its own cleanup, either reactively from order.after_save or from a scheduled run sweep. Cart recovery before that point works off the logged-in customer's saved cart. Walkthrough in Recipes.md — Recovering failed and unpaid 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 via ctx.continue.data — same shape for both named scripts and sw.task.bg closures.
  • 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 of sleep-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 at 0. 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.bg closure 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 a require()'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 call cur.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

Where transact runs. Anywhere sw.ledger itself is available: record hooks (*.before_save, *.after_save, *.after_delete), cart / checkout / payment hooks, template.before_render, fetch routes, widgets, scheduled scripts and background tasks. It is not reachable from {% hook %} tag or block.* handlers — those get the render-only surface described above, so stage ledger work in template.before_render and read the result from ctx.data.bindings. A transact cannot be nested inside another transact; inside the closure, use the tx object for every ledger op.

A ledger write is not tied to the record that triggered it. transact is all-or-nothing across ledger accounts — it is not joined to the save that ran your hook. In a *.before_save handler the record has not been written yet, and a later handler or the store's own validation can still reject it, leaving your balances moved for a record that never existed. Do ledger work in *.after_save / *.after_delete, where the change is already committed, and pass an idemKey derived from the record and the transition you are reacting to (`order:${ctx.data.id}:paid`) so a re-fired event settles once:

// points.js — award once when an order first becomes paid
exports.handler = (ctx) => {
    if (ctx.data.payment?.status !== "paid") return;
    if (ctx.old_data?.payment?.status === "paid") return;    // already awarded
    sw.ledger.transact((tx) => {
        tx.credit("loyalty", ctx.data.customer_id, Math.floor(ctx.data.total), {
            ref: String(ctx.data.id),
            idemKey: `order:${ctx.data.id}:paid`,
        });
        tx.credit("loyalty", "_total_issued", Math.floor(ctx.data.total), {
            idemKey: `order:${ctx.data.id}:paid`,
        });
    });
};

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

Removing an account. Compaction bounds an account's history; remove disposes of the account itself, with its history and idempotency markers. Reach for it when your account keys include a moment — a count per time slot, a quota per day, a tally per order — because every key you retire that way stays behind forever otherwise:

sw.ledger.remove("cap", "resource_9", "2026-09-07T13:00:00Z");   // → { removed: true|false }

An account still holding a balance is refused — deleting it would silently reset a live count — so settle it first, or pass { force: true } if you have already decided the balance is meaningless. removed: false means there was nothing there, which makes a re-run of a sweep harmless. Walk the accounts you want to retire with list (it returns them in path order, so a timestamp as the last path segment comes back oldest-first) and remove them a bounded batch at a time.

Amounts are integers only (use minor units like cents for money). A transact may 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 same transact rather than sum-ming millions of accounts.

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.

The ledger is also the right primitive for live aggregates (record counts, sums, averages) maintained from after_save/after_delete hooks, with a "recount" action to heal drift — walkthrough in Recipes.md — Live counters & aggregates.

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

Excel Bridge (sw.excel)

For filling in templates, generating reports, and producing specifically-formatted .xlsx workbooks — not bulk data. Because a formatted workbook is held whole (styles, merged cells, images and all), it is deliberately capped — a source over ~20 MB, one that expands past ~16 MB, or a workbook past ~500,000 cells is rejected. For a large export, use sw.csv, which streams and has no such ceiling.

Two factories return a workbook handle:

  • sw.excel.create() → a new workbook (one sheet, "Sheet1").
  • sw.excel.open(source) → an existing workbook. source is a string, raw bytes, or a .body stream (sw.files.download(path).body, fetch(url).body). Opening preserves everything in the file — styles, merged cells, images — so you can fill a branded template and save it back unchanged except for the cells you touched.

Workbook methods:

MethodDoes
setCellValue(sheet, cell, value)Write one cell; numbers/booleans keep their type.
getCellValue(sheet, cell) → stringRead one cell.
setSheetRow(sheet, startCell, [..])Write a row rightward from startCell.
getRows(sheet)[[..]]All rows as string arrays.
getSheetList() / newSheet(name) / deleteSheet(name) / setSheetName(old, new)Manage sheets.
mergeCell(sheet, topLeft, bottomRight)Merge a range.
newStyle(def) → id / setCellStyle(sheet, topLeft, bottomRight, id)Define & apply a style.
setColWidth(sheet, startCol, endCol, w) / setRowHeight(sheet, row, h)Sizing.
addPicture(sheet, cell, source, opts?)Embed an image anchored at cell.
setCellFormula(sheet, cell, formula)Set a formula.
save() → readableFinalize; returns { body, bytes(), … } (the sw.files.download shape).

save() returns a readable so sw.files.upload("out.xlsx", wb.save()) streams the workbook to storage without copying the bytes through your script; it is re-callable.

// Fill a branded template and save it — the logo, merges and styling are preserved:
const wb = sw.excel.open(sw.files.download("templates/spec.xlsx").body);
wb.setCellValue("167 - Area Rugs", "B3", "SKU-123");
wb.setCellValue("167 - Area Rugs", "C3", 4999);          // stays numeric
sw.files.upload("exports/spec-filled.xlsx", wb.save());

// Or generate a formatted report from scratch:
const rep = sw.excel.create();
rep.setSheetName("Sheet1", "Summary");
rep.mergeCell("Summary", "A1", "C1");
const title = rep.newStyle({ font: { bold: true, size: 14 }, alignment: { horizontal: "center" } });
rep.setCellValue("Summary", "A1", "Monthly Report");
rep.setCellStyle("Summary", "A1", "C1", title);
rep.setSheetRow("Summary", "A3", ["SKU", "Units", "Revenue"]);
rep.setSheetRow("Summary", "A4", ["SKU-123", 42, 2099.58]);
sw.files.upload("reports/monthly.xlsx", rep.save());

newStyle(def) accepts a style object with font ({ bold, italic, size, color, family }), fill ({ type, color, pattern }), alignment ({ horizontal, vertical, wrap_text }), border ([{ type, color, style }]), and number_format / custom_number_format. Cells read back as strings via getCellValue; values you write keep their type — a number stays a number in the cell, not text.

addPicture(sheet, cell, source, opts?) embeds a logo or image. source is image bytes, a string, or a .body stream — so the bytes flow straight from sw.files.download(path).body, fetch(url).body, or sw.gdrive.download(...).body with nothing extra. The format is sniffed from the bytes (PNG, JPEG, GIF, BMP, TIFF, SVG); pass opts.extension (e.g. ".png") to be explicit. opts: { extension, scale, scale_x, scale_y, offset_x, offset_y, alt_text, lock_aspect_ratio, auto_fit }. Images are capped (~8 MB each, ~24 MB per workbook).

// Fill a template AND stamp a stored logo onto it:
const wb = sw.excel.open(sw.files.download("templates/invoice.xlsx").body);
wb.addPicture("Sheet1", "F1", sw.files.download("public/logo.png").body, { scale: 0.5, alt_text: "Logo" });
wb.setCellValue("Sheet1", "B3", order.number);
sw.files.upload("out/invoice.xlsx", wb.save());

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". This tray is staff-facing only; to email the shop's customers, use sw.notify.customer below.

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 and sw.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 numeric id and a human code — if you only have the code, link to /coupons?q={code} (the list, pre-filtered) instead. A bare list path like /coupons is fine when you don't have a specific record.
  • Category is manifest-bound. category must be one of your declared notify_categories keys; a blank or undeclared category throws. The stored category is namespaced to plugin:<yourId>:<key>, so it never collides with another plugin's or a core category. (The label is what staff see in their preferences; it defaults to a humanized key.)
  • Source is enforced. source is always set to your plugin id — a plugin can't impersonate the platform or another plugin, and list/dismiss only ever touch your own entries.
  • Idempotency. A non-empty dedupeKey makes create a 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, create throws. 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. create honours those preferences automatically. Pass email: false to suppress email entirely (in-app only); you cannot force an email past a user's opt-out (anti-spam). This is the way to alert staff.

Emailing customers (sw.notify.customer)

The same bridge sends email to the shop's shoppers — order-adjacent notices, status updates, whatever your plugin is for. Every send belongs to a category you declare, and each category is its own subscribe/unsubscribe choice the shopper controls under Account → Email Preferences on the storefront (and from the unsubscribe link in the footer of every email you send). You can never mail someone who has opted out.

Declare your categories first, in customer_notify_categories — the shopper-facing sibling of notify_categories. A plugin that declares none cannot email customers at all.

// manifest.json
"customer_notify_categories": [
    {
        "key": "shipment-delay",
        "label": "Shipment delays",                                   // what the shopper sees
        "description": "We'll email you if your order is running late.",
        "class": "transactional",                                     // transactional (default) | marketing
        "default": "on"                                               // on (default) | off
    },
    { "key": "restock", "label": "Back in stock", "class": "marketing" }
]
const { sent, reason } = sw.notify.customer({
    customer_id: order.customer.id,       // REQUIRED
    category: "shipment-delay",           // REQUIRED — one of your declared keys
    subject: "Your order is running late",
    template: "./emails/delay.liquid",    // your own template file; or pass html / text instead
    data: { order },                      // bindings for the template
    dedupeKey: `delay:${order.id}`        // optional idempotency (24h)
});
if (!sent) console.log("not delivered:", reason);
  • Category is manifest-bound and namespaced. The stored key is plugin:<yourId>:<key>, so it never collides with another plugin's or with the store's own categories. A blank or undeclared key throws.
  • You cannot send under a store category. orders, shipping, subscriptions, account and marketing belong to the store itself — declare your own topic instead. This is what keeps a shopper's preferences meaningful: unsubscribing from your notices never silences their order confirmations.
  • Class decides the default. transactional (something the shopper did — an order, a booking, a return) is on unless they turn it off. marketing is always off until the shopper explicitly opts in; a "default": "on" on a marketing category is ignored.
  • Suppression is not an error. sent: false means the shopper opted out, or a dedupeKey already covered this message. Only a genuine failure (bad arguments, an undeclared category, a rate limit) throws.
  • Content. template is a path to one of your own .liquid files ("./emails/delay.liquid"); alternatively pass html or text directly. Your content is rendered inside the store's email layout, so it stays on-brand, and the unsubscribe/preferences footer is added for you — you never build it yourself.
  • Rate limits. ~60/min per plugin, ~300/min per shop, and ~20/day per recipient. The per-recipient cap is the one to design around: a shopper's inbox is not a log.
  • Marketing has a monthly ceiling on top of that. A "class": "marketing" topic draws on the store's small monthly marketing allowance (none at all on Free); once it's spent, further marketing sends return sent: false and the merchant is told once. If your plugin IS the marketing channel, implement email.marketing and deliver through your own provider — those sends aren't metered at all.
  • Transactional topics have no monthly ceiling — only the store's per-hour and per-day sending rate, which is set well above a busy day and shared with the store's own order and shipping mail. sent: true still means accepted: a message that later exceeds the store's rate is dropped at delivery, so don't treat a send as a delivery receipt.
  • Every recipient has their own ceiling too, across everything the store sends them — your messages, the store's order mail, every other app's. It is comfortably above the ~20/day one plugin may send one shopper, so spending your own allowance can't starve their order confirmations, but it does mean a shopper you mail heavily has less room for everyone else. Design for the inbox, not the limit.
  • sw.email.notifyCustomer is retired and sends nothing. It had no category, so the only way a shopper could stop mail sent through it was to unsubscribe from everything the store sends. Calls are now a no-op that records a warning in your app's logs — move them to sw.notify.customer and declare the topic in your manifest.

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" }
});
  • action and entity_type are required; entity_id, message, before, and after are optional. Choose any action label that reads well in the log (e.g. "sync.completed", "webhook.received").
  • before / after capture a change. Pass both and the log displays only the fields that differ, so the merchant sees the exact transition. Call sw.audit.log right 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, log throws. Record meaningful actions, not every loop iteration.

Labelling the entries your writes produce — sw.audit.note

Saving a product, order, customer or coupon already records its own audit entry, with the before/after fields the merchant sees in Audit Logs. Those entries carry no message, so a sync run reads as a wall of identical "Product UPDATE" rows. sw.audit.note(message) attaches a message to every such entry your code produces from then on — the why behind the change:

for (const row of rows) {
    // Say which source record drove this write; the entry the save records
    // below is stamped with it.
    sw.audit.note(`Rinven sync — rug ${row.rug_no} (${row.collection})`);
    sw.products.save({ id: row.product_id, price: row.price });
}
sw.audit.note("");   // done — later writes go back to unlabelled
  • Sticky until changed. The note applies to every entry recorded after it, for the rest of the run. Set it per item (as above) so each entry names its own item, and pass "" when the labelled work is over — a stale note on unrelated writes is worse than no note.
  • Only fills the blank. An entry that already has a message of its own — including anything you record with sw.audit.log — keeps it. The note is a fallback, not an override.
  • Scoped to your run. It never labels anyone else's writes, and a nested run (a sw.task.run closure) starts from your note but its own changes stay inside it.
  • Keep it short. Long notes are truncated (~200 characters). One line naming the item and the reason.

Hooks Bridge (sw.hooks)

Bulk work is the one place CRUD hooks get expensive. An importer writing 5,000 products fires 5,000 rounds of product.after_save, and every handler in the shop runs for each one — so an import can spend more time in other plugins' handlers than in its own work. sw.hooks.mute turns the named hooks off for one block:

// mute(names, fn) — names is one hook or an array; fn runs with them suppressed
exports.import = (ctx) => {
    const rows = sw.csv.parse(ctx.data.file);
    sw.hooks.mute(["product.before_save", "product.after_save"], () => {
        for (const row of rows) {
            sw.products.save({ sku: row.sku, name: row.name, price: row.price });
        }
    });
    return { imported: rows.length };
};

Names are exact ("product.after_save"), a family ("product.*", "record.stock.*"), or "*" for every hook. mute returns whatever fn returns.

  • It silences every plugin's handlers, not just yours — that's the point (the cost is other plugins' handlers), and it's why the rules below are strict.
  • It lasts for the callback only. Hooks are restored when fn returns or throws, so a failed import can't leave the store's hooks off.
  • It's on the record. Each mute writes an entry to your plugin's log naming the hooks and when — a merchant asking why their integration missed a product will find it there.
  • It can't reach past your run. Another request, another plugin's run, and anything you hand to sw.task.bg are unaffected; a sw.task.run closure inherits your mutes but its own end with it.
  • Use the narrowest names that do the job. Muting "*" for a product import also silences order and customer handlers that had nothing to do with it.
  • Reach for it only for bulk. For a handful of writes the hooks are the feature, not the overhead — and a plugin that mutes routinely is one that's fighting the store's other plugins.

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.bg closure): 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) => {
      const jobId = bg.args;
      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). emit is server-side only; on is widget-side only. A widget can't emit onto the bus — to talk to the server it already has sw.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. data must 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, emit throws.
  • 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.
  • emit is 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 inside sw.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:

  1. Paid plan. The shop must be on a paid tier (Pro / Business / Enterprise / Dedicated). On the Free tier sw.container does not exist.
  2. Manifest capability. Declare "containers": true in manifest.json. Without it the namespace is omitted.
  3. 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 (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 in SW_UPLOAD_URL; provided separately for Authorization if 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:

  1. POST to SW_UPLOAD_URL with the token and a JSON body { "path": "<relative/path.ext>", "contentType": "<mime>" }. The response is { "url", "method", "headers", "path" }.
  2. PUT the raw artifact bytes to url using method (always PUT) and the returned headers (send the exact Content-Type you 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. An OCR widget chains the whole flow end to end — sw.pickFile an image → sw.files.signedUrl it as INPUT_URL → run an OCR image that uploads result.txt + meta.json via SW_UPLOAD_URL → a container.job.completed hook that fires sw.notify and pushes the signed result links over sw.bus to the live widget. Full walkthrough in Recipes.md — Offloading heavy work to a container job.

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. The job's actual cost lands on its next invoice.
  • "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.ledger charge 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; costCents on 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.
  • Timeouts are enforced. A job that is still running when its timeoutMs is up is stopped and finalized as failed (error: "timeout"), and you are billed for the time it used. Omit timeoutMs and the job gets your plan's maximum — so set it to what the work actually needs rather than letting a stuck job run to the ceiling.
  • 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).

Zip Bridge (sw.zip)

Read and write .zip archives that live in your files without ever holding a whole archive in memory — reads pull only the bytes they touch, and writes stream straight to storage. It composes with sw.files: every entry you read comes back as the same readable ({ body, text(), json(), bytes() }) that sw.files.download returns, and every write source accepts a string, bytes, or a .body stream — so you can pipe a stored file straight into an archive entry, or an entry straight back into storage, with nothing buffered in between.

All paths are relative to your files (same scoping as sw.files), so an archive or its members can never escape your shop's storage.

Reading takes a path or an in-memory archive. The three read verbs (list, open, extract) accept either a stored file path (read a piece at a time — nothing loaded) or a source you have in hand: bytes, or a .body stream — so you can read an uploaded zip without persisting it (ctx.request.formData().file("archive").body). Because a zip's directory sits at the end of the file, an in-memory source has to be buffered whole first, so it's capped at ~32 MB; a larger archive should be saved to your files (that streams, no cap) and read by path.

sw.zip.list(source)[{ name, size, compressed_size, dir, modified }]. Reads only the archive's directory (a few KB) — nothing is extracted.

sw.zip.open(source, entry) → a readable { body, text(), json(), bytes(), size, content_type } for one entry, decompressed on the fly. .body streams it (pipe to sw.files.upload / sw.csv.reader) without buffering; text()/json()/bytes() buffer, so stream large entries via .body.

sw.zip.extract(source, destPrefix, opts?)[{ path, url, public_url?, size }]. Streams every file member into your files under destPrefix, one at a time. Directory members are skipped and member names are normalized to safe paths, so a member can never write outside destPrefix. opts: { max_bytes? } (total decompressed ceiling, default 512 MB) and { max_entries? } (default 10000) — both guard against a malicious archive; you can lower them but not raise them past the defaults.

sw.zip.create(dest, build){ path, url, public_url?, size } (path dest) or { size } (sink dest). Streams a new archive to dest, which is either a stored file path or a write() sink — an HTTP response's out, so you can zip straight to the client with nothing persisted (or the sw.files.upload streaming sink). build receives a builder:

  • add(name, data) — add an entry; data is a string, bytes, a .body stream, or a whole readable ({ body }).
  • addFrom(name, srcPath) — stream a stored file straight into an entry.

sw.zip.update(path, build){ path, url, public_url?, size }. Rewrites an existing archive: unchanged entries are carried over untouched, then the builder's changes are applied. The builder has add(name, data) / addFrom(name, srcPath) (add or replace a same-named entry) and remove(name). (A zip can't be edited in place, so update produces a fresh archive and swaps it over the original in one step — you never see a half-written file.)

// Build a zipped export, streaming a big CSV straight from storage into it — nothing buffered:
sw.zip.create("exports/backup.zip", (z) => {
    z.addFrom("products.csv", "exports/products.csv");   // stored file -> entry, streamed
    z.add("meta.json", JSON.stringify({ generated: Date.now() }));
});

// Unpack an uploaded import and process one entry as a stream (never fully loaded):
sw.zip.extract("imports/upload.zip", "imports/unpacked");
const cur = sw.csv.reader(sw.zip.open("imports/upload.zip", "rows.csv").body, { header: true });
let row; while ((row = cur.next()) !== null) { /* one row at a time */ }

// Read a zip a visitor just uploaded WITHOUT saving it — pass the .body straight in:
const up = ctx.request.formData().file("archive");   // an uploaded .zip
const names = sw.zip.list(up.body).map((e) => e.name);           // inspect it, or
sw.zip.extract(up.body, "imports/unpacked");                     // extract it to files
// (An in-memory source is capped at ~32 MB; save a larger one to files and read by path.)

// Amend an archive: drop one file, replace another, add a new one:
sw.zip.update("exports/backup.zip", (z) => {
    z.remove("meta.json");
    z.add("products.csv", sw.files.download("exports/products-v2.csv").body); // replace
    z.add("README.txt", "regenerated");
});

// Zip straight to the download response — NOTHING is persisted (a route handler):
module.exports.fetch = (ctx) => ({
    status: 200,
    headers: { "Content-Type": "application/zip", "Content-Disposition": 'attachment; filename="export.zip"' },
    write: (out) => {                         // out is a write() sink
        sw.zip.create(out, (z) => {           // archive streams straight to the client
            z.addFrom("products.csv", "exports/products.csv");
            z.add("meta.json", JSON.stringify({ generated: Date.now() }));
        });
    },
});

Large archives you do persist. A stored archive is a file like any other — hand a big one to the browser with sw.files.signedUrl (a direct download link) rather than reading its bytes back through your plugin. (If you're generating on demand and don't need to keep it, stream sw.zip.create(out, …) to the response as above — no storage, no cleanup.)

Time Bridge (sw.time)

Dates in a real timezone. Everything the store stores is UTC, but almost nothing a merchant means is: business hours, "orders placed today", "every Monday at 5:15pm". sw.time converts between the two.

You need this more than you think. ShopScript's own Date has no timezone support at all — Intl isn't available, and toLocaleString's timeZone option is accepted and then ignored. Any timezone work you do without this bridge is silently running in UTC.

It defaults to the store's own timezone (Settings → General), so the common case needs no zone argument at all. Every method takes an optional trailing IANA zone name to override it. Instants are epoch milliseconds — the same numbers Date.now() and new Date(ms) use, so they interoperate with ordinary JS dates.

sw.time.zone();                      // → "America/New_York" (the store's zone; "UTC" if unset)
sw.time.valid("Europe/Lisbon");      // → true — validate before storing merchant input

// A wall-clock reading → an instant
const ms = sw.time.parse("2026-09-07T17:15");            // store's zone
const jp = sw.time.parse("2026-09-07T17:15", "Asia/Tokyo");

// An instant → local calendar fields
sw.time.parts(ms);
// → { year: 2026, month: 9, day: 7, hour: 17, minute: 15, second: 0,
//     weekday: "mon", weekday_index: 1, year_day: 250,
//     offset: -240, abbrev: "EDT", date: "2026-09-07", time: "17:15",
//     local: "2026-09-07T17:15:00", iso: "2026-09-07T17:15:00-04:00" }

sw.time.offset(ms);                  // → -240 (minutes from UTC at that instant)
sw.time.format(ms, "ddd, MMM D [at] h:mm A z");   // → "Mon, Sep 7 at 5:15 PM EDT"
sw.time.add(ms, { weeks: 1 });       // → a week later, still 17:15 locally
sw.time.startOf(ms, "week");         // → 00:00 local on the containing Monday

parse accepts YYYY-MM-DD, YYYY-MM-DDTHH:MM, and YYYY-MM-DDTHH:MM:SS (a space instead of T works too) — a bare date means local midnight. This is also the shape an <input type="datetime-local"> posts, so a form value goes straight in.

Format tokens are the moment-style ones you already know. Anything inside square brackets is emitted literally.

YYYY YY yearMMMM MMM MM M monthDD D daydddd ddd weekday
HH H hour (24)hh h hour (12)mm m minutess s second
A a AM/PMZ ±HH:MMZZ ±HHMMz zone abbreviation

Three behaviors worth knowing, because they're the ones that bite:

  • add with weeks/days/months/years moves the wall clock; with hours/minutes/seconds it moves elapsed time. A week after a Sunday 5:15pm lesson is the next Sunday at 5:15pm even when the clocks changed in between — adding 7×24 hours would land on 4:15 or 6:15. Conversely "in two hours" means two real hours. This is the distinction that makes recurring appointments correct.
  • Month arithmetic clamps. January 31st plus a month is February 28th (or 29th), not March 3rd.
  • The two hours a year a wall clock is ambiguous resolve predictably. A time that never happened (the spring-forward gap — 02:30 where the clock jumped 01:59 → 03:00) shifts forward to 03:30. A time that happened twice (the autumn fold) resolves to the first occurrence. Both match what a browser's Date does, so client and store agree.

An unrecognized zone name throws rather than falling back to UTC — a typo that quietly became UTC would shift a whole schedule with nothing to show for it. Validate merchant input with sw.time.valid() before you store it.

sw.time costs nothing against your rate budget (it's pure arithmetic, no lookups), and it is one of the few bridges also available inside hook tags and block wrappers, so a template hook can render a stored instant in the store's timezone directly.

The store's timezone is readable elsewhere too — as ctx.shop.timezone in route handlers and as shop.timezone in Liquid (see Themes.md). It's blank when the merchant hasn't set one, in which case the store's clock is UTC.

Other Bridges

  • Email: sw.notify.customer({ customer_id, category, subject, template, html, text, data }) is the way to email a customer — declare the topic in your manifest customer_notify_categories so the shopper can subscribe/unsubscribe from it. (sw.email.notifyCustomer, the category-less predecessor, is retired — it sends nothing and logs a warning.) To alert staff, use sw.notify.create (declare notify_categories; its email channel respects each staff member's per-plugin notification preferences) rather than emailing them directly.
    • 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. port defaults to 587; secure is "tls"/"ssl" (implicit TLS, typical for port 465), "starttls" (require upgrade), or omit to opportunistically STARTTLS. to/cc/bcc accept a string, a comma-separated string, or an array. Provide html, text, or both (both → a multipart/alternative message). Throws on a transport error. The SMTP server must be reachable on the public internet — connections to internal, private, or loopback addresses are refused. The host, username, password, from, fromName, and replyTo fields support {secret.KEY} expansion (like fetch/sw.sql), so keep credentials in sw.secrets and 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 the email.send hook + ctx.stop() to route all transactional mail through the shop's SMTP.

sw.email is now SMTP delivery only. notifyCustomer is retired (see above) — smtpSend is what remains, and it is unaffected: it exists to take over delivery of the store's own mail through the merchant's mailbox, which sw.notify.customer does not do.

Transactional use only. sw.email is 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 via fetch for 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 typeBudgetWhy
template.before_render2 secondsThe page's data-loading hook, and the only render hook with the full bridge surface — it gets twice the rest because reading stored data is what it's for.
Every other render hook, filter and {% hook %} tag1 secondThey run on the page-render critical path and block the visitor's page load, so they must be fast. Loading your script (top-level code) is its own step, with half a second.
Data/event hooks (*.before_save, *.after_save, cart/search hooks, etc.)5 secondsRealtime hooks that guard API writes. Enough headroom for one external call (fraud, tax, address validation), but heavy work belongs in sw.task.bg.
checkout.* hooks10 secondsThey price and validate an order a shopper is waiting to pay for, and often call a tax, fraud or address service on the way.
payment.* hooks20 secondsThe call to the provider is the hook, and how long it takes is the provider's decision, not yours. The extra room is there so an ordinary slow authorization is not cut off mid-flight with the shopper's card already submitted. It is not licence to do other work here.
Route handlers, widget fetch(ctx), the "Run" button, MCP tools30 secondsSomeone is waiting on the response. Long or expensive work belongs in a background task that stores its result for the handler to return.
Lifecycle hooks (plugin.activate/deactivate/uninstall/change_version)60 secondsNetwork provisioning / teardown of external resources.
Background & scheduled scripts (sw.task.bg, cron)10 minutesLong-running batch work.

Outbound fetch() calls (connect + response headers) are bounded at 60 seconds or whatever your run has left, whichever is shorter — so on a 30-second route handler, 30 seconds is the real ceiling. Body streaming is bounded by the run's remaining budget rather than by 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:

LevelRecords
errorerrors only
warnwarnings + errors
infonormal logs (default — empty value also means info)
debugadds console.debug
traceadds 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.

Temporary script blocks

A script whose runs keep getting stopped — going past its time budget, holding more data than its memory budget allows, looping without finishing, or exceeding the sw.* bridge rate limits — is temporarily paused by the platform. Five stops within an hour trigger it; below that, each stop just costs you that run.

A pause applies to the request path — event hooks, routes, widget renders, page rendering — and leaves background tasks (sw.task.bg, scheduled scripts) running, since that is where heavy work belongs. It starts at a few minutes and grows if the stops continue. Each refused run writes an error entry to the plugin's logs saying how long the pause lasts and, when the cause is something you can fix yourself, why it tripped:

ERROR [acme] hook order.created: script paused for 5m0s — it repeatedly held more data than its memory budget allows; fix the script and it resumes automatically

Log entries are throttled to about one per minute per script, so a paused widget on a busy storefront page can't flood your logs. The pause applies to the exact code that overran: it lifts by itself when it expires, and shipping a fixed version takes effect immediately — new code is never paused.