Plugin API Reference
ShopsWired plugins are written in modern JavaScript (ES2015+) and run server-side on ShopScript — ShopsWired's custom synchronous JavaScript runtime that powers every customization script on the platform. Use const/let, arrow functions, template literals, destructuring, optional chaining, and for…of freely — the examples in this guide do. All bridge calls are synchronous—no callbacks or promises needed. This reference details the available APIs, execution contexts, bridges, and hooks for robust plugin
development.
1. The manifest.json
The manifest defines the plugin identity, scripts to execute, and configurable settings.
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Acme Co.",
"source": "private",
"scripts": [
{ "path": "hooks.js" },
{ "path": "render.js", "routes": ["/product/*", "/"] },
{ "path": "cron.js", "schedule": "* * * * *" }
],
"settings": [
{ "key": "tracking_id", "type": "text", "label": "Tracking ID" },
{ "key": "custom_css", "type": "editor", "label": "Custom CSS", "options": { "language": "css" } }
],
"custom_records": [
{
"id": "demo_record",
"name": "Demo Records",
"group": "My Plugin",
"fields": [
{ "name": "title", "type": "string", "list": true },
{ "name": "value", "type": "number" }
]
},
{
"id": "internal_log",
"name": "Internal Logs",
"hidden": true,
"fields": [
{ "name": "message", "type": "string" }
]
}
]
}- author: The plugin (or theme) author's name. Shown on the plugin/theme listing when published, so set it to your company or developer name to get attribution on published plugins and themes.
- source: Set to
"private"to protect a paid or proprietary plugin — the platform refuses to serve its files (pull, export, source-view) to anyone but the plugin owner, so installed shops can run it but cannot download or copy its code. Omit it (or use"") to keep the plugin open-source. See Protecting Source Code. - routes: A filter that restricts a hook script to run only on matching storefront routes — it does not register an endpoint. (To register an HTTP endpoint, use
type: "route"+method+route_pathinstead — see Route Handlers.) - route_path: With
type: "route"and amethod, registers a storefront HTTP endpoint at this path (wildcards allowed) handled by the script's exportedfetchfunction. - templates: Restricts a script to run only when rendering specific template names.
- dataloaders: Restricts a script to run only on pages using specific data loaders (e.g.
["checkout"],["product"],["cart"]). - schedule: 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 are runnable this way; hook, route, widget, and internallibfiles are not. - assets: Array of directories (e.g.
["js/", "css/"]) containing static files to be publicly exposed. Files in these directories can be accessed at/plugin-assets/{plugin_id}/.... For example, a file atjs/abc.jsbecomes accessible at/plugin-assets/my_plugin/js/abc.js. - settings: Supported field types include:
text, textarea, number, checkbox, select, image, color, tags, link, html, editor, richtext, menu, layout, group. - custom_records: Each record type supports the following optional properties:
"hidden": true— Hides the record type from the admin interface. Useful for internal data (e.g. logs, caches) that should only be accessed programmatically viasw.records."group": "Name"— Groups related record types under a named section in the admin sidebar menu. Records sharing the same group name are displayed together.
Action Scripts ("type": "action")
An action is an admin-triggered script with no schedule — a button the shop runs on demand from the plugin's Status panel. Unlike "Run now" on a scheduled script, an action can prompt for parameters before it runs, and the entered values are exposed to the script as ctx.params.*.
{
"scripts": [
{
"path": "reindex.js",
"type": "action",
"label": "Reindex catalog",
"export": "run",
"params": [
{ "key": "force", "type": "checkbox", "label": "Force full reindex" },
{ "key": "batch", "type": "number", "label": "Batch size", "default": 500 }
]
}
]
}// reindex.js
module.exports.run = (ctx) => {
const force = ctx.params.force // boolean
const batch = ctx.params.batch // number
sw.log.info(`reindex force=${force} batch=${batch}`)
// ...
}- label: (Optional) Text shown on the Run button; defaults to the script path.
- export: (Optional) Entrypoint function name; defaults 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'sdefaultapplied when blank) are available asctx.params. - Like scheduled "Run now", actions run in the background — output goes to the Logs tab, not streamed back — so they work for
private-source plugins too. The plugin must be active. Write actions to be idempotent and safe to re-run.
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, and 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.
Splitting Code Across Files (require)
Plugins can split their JavaScript across multiple files using Node-style require(). Paths resolve relative to the requiring file and are sandboxed to the plugin's own directory — absolute paths and ../ escapes are rejected, and there is no node_modules resolution (the engine ships no npm packages).
// cron.js
const feed = require('./lib/feed');
module.exports.run = function (ctx) {
const items = feed.fetchLatest();
console.log('fetched', items.length, 'items');
};// lib/feed.js
module.exports.fetchLatest = function () {
return fetch('https://api.example.com/feed').json();
};Compiled modules are cached per plugin and re-used across runs — require('./lib/feed') parses and compiles lib/feed.js exactly once until the source changes. Saving a file override (or reinstalling the plugin) invalidates the cache automatically, so the next run picks up your edit.
require() is available in scheduled scripts, durable bg tasks (sw.task.bg), route handlers, widget scripts, hook scripts, and sw.task.run sub-VMs. It is not available inside storefront template render scripts (those share a single VM across plugins).
Declaring Permissions
A plugin can declare its own permissions — capability flags an admin grants to individual shop staff under Settings → Users → Permissions. Each user can hold any number of them. They are separate from the built-in shop roles (owner/admin/staff); use them when one plugin needs finer-grained, per-user access than the shop role provides.
"permissions": [
{ "key": "view_orders", "label": "View Orders", "description": "See the recent-orders count." },
{ "key": "view_financials", "label": "View Financials", "description": "See revenue totals." }
]key— the identifier you check at runtime (stable;[a-zA-Z0-9_-]).label/description— shown in the Users tab checklist.labeldefaults tokey.
Grants can be assigned two ways, and a user's effective set is the union of both:
- Per user — checked off for an individual under Settings → Users → Permissions (one-off overrides).
- Per role — baked into a role under Settings → Users → Roles, so everyone holding that role inherits them without re-adding per user. (Built-in roles carry no plugin grants; custom roles can.)
Either way they're surfaced to your widget scripts only — they're a staff concept, so storefront route/hook contexts don't receive them:
ctx.permissions— array of the keys this plugin declared that the current user holds (from their role and their per-user grants, merged + de-duped), e.g.["view_orders"]. De-namespaced, so just check your own keys; you never see other plugins' grants.ctx.role— the user's role slug. This is a built-in role (owner/admin/staff) or a shop-defined custom role slug, so treat it as an opaque string for display/branching, not an exhaustive enum. Gate capabilities onctx.permissions, not onctx.role.
// widgets/snapshot.js
module.exports.fetch = function (ctx) {
const can = (p) => (ctx.permissions || []).indexOf(p) !== -1;
if (!can('view_orders')) {
// omit the orders panel entirely for users without the grant
}
// ctx.role is also available, e.g. for owner/admin-only affordances
};Enforce permissions on the server side of fetch(ctx) (skip the query, omit the data) — not just in the rendered HTML — since the grant gates what the script computes.
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.
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 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. 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. Deactivating does not cascade — dependencies are left active, since other plugins may still rely on them. In the installed-plugin sidebar each dependency shows 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) 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.
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. Declare only the ordered plan keys in the manifest:
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"plans": ["free", "pro", "biz"]
}The keys are display/identity only — the price, display name, billing interval, and feature list are filled in at publish time (in the Publish dialog), one row per declared key, and stored on the marketplace listing. This keeps prices out of your source and lets you re-price without a code change. The first key is shown first in the comparison grid. Rules:
- Billing types per tier:
free,one_time, orsubscription(monthly). A tier priced at $0 is allowed — a free-but-keyed plan (e.g. a"hobby"tier). - Switching: shops upgrade/downgrade between subscription tiers in-app (Stripe prorates the change). Moving to a $0 tier removes the add-on.
one_timetiers are mutually exclusive and cannot be switched in-app (uninstall and repurchase). - Publish validation: the priced tiers must match the manifest keys exactly (no extras, none missing).
Reading the active plan — ctx.plan. Every plugin-executed script (hooks, routes, widgets, scheduled runs, payment hooks) receives the shop's active plan key as ctx.plan:
module.exports.hook_product_save = (ctx) => {
if (ctx.plan === 'biz') {
// business-tier feature
} else if (ctx.plan) {
// any selected paid/keyed plan (pro, hobby, …)
} else {
// ctx.plan === '' → no plan selected, or a legacy plugin with no `plans`
}
};ctx.plan is the literal key the shop selected (including a $0 keyed tier). It is the empty string '' only when no plan has been selected yet, or for a plugin that declares no plans at all — so a plain if (ctx.plan) means "a plan is selected". Gate features off the key; don't trust client-side checks for anything billable.
In-App Purchases (sw.iap)
Beyond the plan tier, a plugin can sell in-app purchases to the merchant after install — a one-time unlock or a pack of consumable credits. The shop owner is charged and you (the developer) are paid 70% via Stripe Connect, the same rails as marketplace plan purchases. This works for free and paid plugins alike. There are two product types:
one_time— a permanent unlock. Grants an entitlement you check withsw.iap.entitled(key).consumable— a credit balance you debit withsw.iap.consume(...)(e.g. AI tokens, SMS sends).
1. Declare the catalog in manifest.json. No price here — you set the amount at runtime, so the merchant sees the exact charge on the approval screen. The keys gate sw.iap.* (a plugin can only transact products it declared) and badge the plugin as "offers in-app purchases".
"iap_products": [
{ "key": "pro-unlock", "name": "Pro Features", "type": "one_time" },
{ "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.
2. Request a purchase — the merchant must approve. sw.iap.requestPurchase never charges; it mints a short-lived signed token and returns a confirmation_url. Send the shop owner there: they review the price and approve, and only then is the charge created and the entitlement/credits granted. There is no silent-charge path.
const res = sw.iap.requestPurchase({
product: "credits", // must match a declared key
amount: 2000, // cents — shown to the owner, charged on approval
currency: "usd", // optional, defaults to usd
credits: 1000, // consumables: units granted on approval
description: "1,000 Action Credits", // shown on the approval screen + invoice
returnUrl: "/admin/...", // optional: where to send the owner after approve/decline
});
// → { purchase_id, token, confirmation_url }How you present the approval depends on the surface:
- Dashboard widget — return
res(which includestoken) to your widget's client JS and call the bundledawait sw.iap(token)client helper. The admin renders the approval modal in place (likesw.pickFile), so buying feels native. 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 plugin4. 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.
2. Execution Context & Globals
Every hook, route handler, and run/fetch function receives a context object
ctx (Execution Context) as its first parameter. The plugin's
configured settings live on that context as ctx.settings.
ctx.settings (Object)
Contains the plugin's configuration defined in manifest.json, keyed by each setting's key.
const trackingId = ctx.settings.tracking_id;ctx (Context Object)
The first parameter passed to every hook, route handler, and run/fetch function is the context object, conventionally named ctx. Its shape varies depending on the hook or route handler.
ctx.customer(Object): Full customer object (available in Render Hooks).ctx.customer_id(Number): Logged-in customer ID, or 0 if guest (available in Route Handlers).ctx.request(Object): HTTP request context for Route Handlers (containsmethod, url, path, queryand body readersjson(), text(), arrayBuffer(), formData()).ctx.continue(Object): Continuation state for background tasks. Containsctx.continue.depthandctx.continue.data.
ctx.timeoutRemaining() → Number
Returns the milliseconds remaining before the engine forces a timeout. The budget depends on what kind of script is running:
| Script type | Budget | Why |
|---|---|---|
Template render hooks (template.* hooks and {% hook %} tags) | 1 second | They run on the page-render critical path and block the visitor's page load, so they must be fast. |
Data/event hooks (*.before_save, *.after_save, checkout/cart/payment/search hooks, etc.) | 5 seconds | Realtime hooks that guard API writes. Enough headroom for one external call (fraud, tax, address validation), but heavy work belongs in sw.task.bg. |
Route handlers & widget fetch(ctx) | 60 seconds | Request handlers; gives room for outbound calls and computation. |
Lifecycle hooks (plugin.activate/deactivate/uninstall/change_version) | 60 seconds | Network provisioning / teardown of external resources. |
Background & scheduled scripts (sw.task.bg, cron) | 10 minutes | Long-running batch work. |
Outbound fetch() calls (connect + response headers) are bounded at 60 seconds — body streaming is bounded by the run's remaining budget, not this limit.
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.
console & Logging
Available methods: console.log(), console.info(), console.warn(), console.error(), console.debug(), and console.trace(). Each call is captured at its named level and stored in the admin UI log viewer.
Shop log level
The shop's Log Level (Settings → Developer → Log Level) controls which messages are recorded. Lower levels are quieter:
error— errors only.warn— warnings + errors.info— normal logs (default; an empty value also meansinfo).debug— addsconsole.debug.trace— addsconsole.trace,console.time/timeEnd, and automatic timing of every hook, export, andsw.*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, so they are safe to leave in place. Labels are per-VM, so two scripts can use the same label without colliding.
module.exports = {
"order.before_save": function (ctx) {
console.time("fraud-check");
const verdict = fetch("https://fraud.example.com/check", {
method: "POST",
body: JSON.stringify(ctx.data)
}).json();
console.timeEnd("fraud-check"); // TRACE row: "fraud-check: 312ms"
if (verdict.block) throw { error: "Order blocked" };
}
};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. The pluginID column in the log viewer makes it easy to see which plugin is spending the most time. A typical trace stream 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: 4821msTurn trace off when you're done investigating — log volume is high.
3. Event Hooks
Export functions from your scripts to intercept core system events.
Data Hooks
Intercept database modifications. To abort a save, you can throw an error:
throw { error: "Reason" };
product.before_save/product.after_save/product.before_delete/product.after_deleteorder.before_save/order.after_save/order.before_delete/order.after_deletecustomer.before_save/customer.after_save/customer.before_delete/customer.after_deletecoupon.before_save/coupon.after_save/coupon.before_delete/coupon.after_deleterecord.<type>.before_save/record.<type>.after_save/record.<type>.before_delete/record.<type>.after_delete(e.g.,record.demo_record.before_save)
ctx.data is the record being written (mutate it in a before_save to change what gets persisted). On an update, ctx.old_data holds the previously persisted state, so you can detect transitions — e.g. only act when a field actually changed — rather than firing on every save.
module.exports = {
"product.before_save": function(ctx) {
if (!ctx.data.sku) ctx.data.sku = "AUTO-" + Date.now();
},
"order.after_save": function(ctx) {
// Payment state lives on ctx.data.payment.status ("pending" | "paid" |
// "failed" | "refunded") — distinct from ctx.data.status, the fulfillment
// status ("created", "processing", "shipped", "payment_failed", …).
// Fire only on the transition into "paid", not on every later save.
const paid = ctx.data.payment && ctx.data.payment.status === "paid";
const wasPaid = ctx.old_data && ctx.old_data.payment && ctx.old_data.payment.status === "paid";
if (paid && !wasPaid) {
// ... react to the order becoming paid ...
}
}
};Checkout & Cart Hooks
coupon.validate
Fired synchronously when a coupon is applied. Allows custom validation logic.
ctx.data.coupon: The coupon object{ id, type, value, min_order, max_uses, uses, active }ctx.data.subtotal: Cart subtotal in centsctx.data.cart: Array of cart items[{ product_id, name, price, qty, set, image }]
module.exports = {
"coupon.validate": function (ctx) {
// Reject if the coupon is strictly for premium products
const hasPremium = ctx.data.cart.some((item) => item.name.indexOf("Premium") !== -1);
if (!hasPremium) {
throw { error: "This coupon is only valid for Premium products" };
}
}
};shipping.calculate
Fired synchronously when shipping options are calculated. Replaces or modifies built-in shipping options.
ctx.data.cart: Array of cart itemsctx.data.weight: Total cart weight in lbsctx.data.address: Customer address{ name, line1, line2, city, state, zip, country }ctx.data.options: Pre-computed built-in options[{ id, name, price, type }]. Override this array to change shipping options.
module.exports = {
"shipping.calculate": function (ctx) {
// Free shipping for orders over 50 lbs
if (ctx.data.weight > 50) {
ctx.data.options = [{ id: "heavy_free", name: "Heavy Freight (Free)", price: 0, type: "free" }];
} else {
// Otherwise, add a custom overnight option to existing ones
ctx.data.options.push({
id: "custom_overnight",
name: "Overnight Delivery",
price: 2499,
type: "flat"
});
}
}
};tax.calculate
Fired synchronously when tax is calculated. Used to override built-in tax calculation.
ctx.data.cart: Array of cart itemsctx.data.subtotal: Subtotal in centsctx.data.shipping: Shipping cost in centsctx.data.address: Customer address- Set
ctx.data.tax(cents) andctx.data.nameto return a custom tax.
module.exports = {
"tax.calculate": function (ctx) {
// Flat 8.25% tax for Texas
if (ctx.data.address.state === "TX" && ctx.data.address.country === "US") {
const taxable = ctx.data.subtotal + ctx.data.shipping;
ctx.data.tax = Math.round(taxable * 0.0825);
ctx.data.name = "TX Sales Tax";
}
}
};cart.calculate_prices
Fired 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, or a time-of-day discount.
ctx.data.items: Array of line items[{ product_id, shop_id, name, set, qty, price }]. Mutateitems[i].price(cents) to override the engine-computed price for that line.
module.exports = {
"cart.calculate_prices": function (ctx) {
const items = ctx.data.items;
// Batch-fetch every product in one call instead of one get() per line.
const products = sw.products.get(items.map((it) => it.product_id));
const byId = {};
for (const p of products) byId[p.id] = p;
for (let i = 0; i < items.length; i++) {
const p = byId[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
}
}
};Only price is read back — mutating qty, name, etc. has no effect. 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
Fired right before an order is created. Used to block the checkout process and to bake in a final price snapshot. While cart.calculate_prices keeps the display fresh while shopping, checkout.before_create is the last moment before the order is persisted, so dynamic-pricing plugins should write their final per-item prices here.
ctx.data.cart: Array of cart itemsctx.data.order: The order object being created{ customer, shipping, totals, items, ... }. Mutatingorder.items[i].price(cents) is read back as the final price; Subtotal and Total are recomputed, while Discount, Shipping, and Tax are preserved (modify those viacoupon.validate/shipping.calculate/tax.calculateinstead).ctx.data.shop: The shop object{ id, name, subdomain, domains, currency, plan_tier, theme, ... }
module.exports = {
"checkout.before_create": function (ctx) {
// Prevent checkout if a specific condition isn't met
const totalQty = ctx.data.cart.reduce((sum, item) => sum + item.qty, 0);
if (totalQty > 10) {
throw { error: "You cannot purchase more than 10 items at once." };
}
// Inspect the shipping address via the order object
if (ctx.data.order.shipping.country === "US" && ctx.data.order.shipping.state === "HI") {
throw { error: "We do not ship to Hawaii at this time." };
}
// Bake in the final dynamic-pricing snapshot
const order = ctx.data.order;
for (let i = 0; i < order.items.length; i++) {
order.items[i].price = computeLivePrice(order.items[i]);
}
// Optional price-lock window — there is no engine-side lock; enforce it
// on later payment attempts via payment.before_intent (below).
order.meta = order.meta || {};
order.meta.price_lock_expires = Date.now() + 10 * 60 * 1000;
}
};checkout.after_payment
Fired asynchronously after a successful checkout payment.
ctx.data.order_id: The newly created order IDctx.data.provider: Payment provider (e.g., "stripe", "square")ctx.data.total: Order total in cents
module.exports = {
"checkout.after_payment": function (ctx) {
// Notify an external system via webhook
fetch("https://api.example.com/webhooks/order_paid", {
method: "POST",
body: JSON.stringify({ order_id: ctx.data.order_id })
});
}
};payment.calculate_adjustment
Fired during checkout submission when the form posts payment_method=<id>. Returns +/- adjustment lines that are appended to order.adjustments and folded into Totals.Total — e.g. a credit-card surcharge or a wire-transfer discount. Multiple plugins compose: each pushes its own entries onto ctx.data.adjustments and the engine keeps them all.
ctx.data.payment_method: The chosen payment method id posted with the orderctx.data.order: The order being created (readorder.totals.subtotal, etc.)ctx.data.adjustments: Array of{ label: String, amount: Number }(cents; negative amounts are discounts). The single-object shape{ label, amount }is accepted for the common one-line case.
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)
}]);
}
}
};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. Surface the adjustments on the receipt template via {% for a in order.adjustments %}{{ a.label }} {{ a.amount | money }}{% endfor %}.
payment.before_intent
Fired 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; include a redirect_url on the thrown object to bounce the customer.
ctx.data.order: The order about to be paid for (inspectorder.meta, totals, etc.)
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 exposed to the storefront via the HTTP 410 JSON response — redirect_url is the conventional one. The storefront's checkout script handles 410 by reading redirect_url and navigating.
Payment Gateway Hooks
Plugins acting as custom payment gateways (defined in manifest with "type": "payment") should implement these hooks to process payments natively.
hook.checkout_payment (Template Render)
Returns the HTML/JS string to render the payment element UI on the storefront checkout page.
module.exports = {
"hook.checkout_payment": function (ctx) {
if (ctx.data.bindings.shop.payment_provider !== 'my-gateway') return;
return `<div id="my-payment-element">Loading...</div>
<script src="https://api.gateway.com/sdk.js"></script>`;
}
};payment.create_intent
Fired when an order is placed and needs processing. Sets the initial payment status and intent data.
ctx.data.provider: The gateway provider namectx.data.shop: The shop objectctx.data.order: The order objectctx.data.payment.method: The tender used —card,cash,bank_transfer,ach, … (distinct fromprovider, the gateway). Seeded from the order, so a plugin can read a pre-selected method; set or override it here and it is persisted on the order (and exposed in Liquid aspayment.method).- Return/Modify: Set
ctx.data.payment.status(e.g., 'paid', 'pending') and optionallyctx.data.payment.id,ctx.data.payment.method,ctx.data.payment.url, orctx.data.payment.client_secret.
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 onctx.data.payment.vault_token(an opaque string the platform stores encrypted and never parses, e.g. a JSON blob of customer + payment-method ids).ctx.data.payment.trial: vault the method but don't charge now (free trial); setstatusto"paid"for the $0 order.ctx.data.payment.off_session+ctx.data.payment.vault_token: a renewal — charge the vaulted token with no shopper present. Setstatusto"paid","failed"(e.g. the card now needs authentication), or"pending".
payment.webhook
Handles provider webhook callbacks. The hook always runs in a known shop's context (the platform resolves it from the URL or, for connected accounts, via payment.webhook_account — see below), so you only locate the order and report the result; you never set the shop yourself.
ctx.data.body: The raw webhook body stringctx.data.headers: Request headers map- Payment update: set
ctx.data.order_id(e.g. from the provider'sreference_id/metadata you stamped atcreate_intent), thenctx.data.payment_idandctx.data.payment_status. Use"paid"to confirm, or"failed"for an async failure (e.g. an ACH return) — the platform then marks the orderpayment_failed, returns the reserved stock, and reverses any wired-supplier ledger. A redundant"paid"on an already-paid order is a no-op, and"failed"never overrides a"paid"order. For a subscription signup whose first charge settles asynchronously, also setctx.data.vault_tokenon the"paid"event so the platform can create the contract with a reusable token (it's normally returned synchronously fromcreate_intentinstead). - Refund update (for refunds that settle asynchronously — ACH, some wallets): set
ctx.data.refund_id(the same id you returned frompayment.refund) andctx.data.refund_status("succeeded"or"failed") — you don't needorder_id, the platform stores the provider refund id on the order and resolves it fromrefund_id. The platform flips the matching refund frompendingto its final state, then marks the order refunded, sends the refund email, restocks, and reverses any wired-supplier ledger. A presentrefund_idtakes precedence over a payment update.
Authenticity is your responsibility. The webhook endpoint requires no admin auth (external providers can't log in), so your hook must verify the signature before trusting anything. Secrets referenced as {secret.KEY} are expanded at the crypto boundary, so a signing secret does not need to be "readable" — pass the placeholder straight to sw.crypto.createHmac(...).
// 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 = sw.crypto
.createHmac('sha256', '{secret.STRIPE_WEBHOOK_SECRET}')
.update(t + '.' + (ctx.data.body || ''))
.digest('hex');
if (!v1 || expected !== v1) throw new Error('invalid webhook signature');
// ... parse ctx.data.body and set order_id + payment_* or refund_* ...
}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. Pair it with sw.payments.linkAccount(accountId) at OAuth-connect time. Own-keys gateways (shop in the URL) don't need this hook.
module.exports = {
"payment.webhook_account": function (ctx) {
ctx.data.account_id = JSON.parse(ctx.data.body || "{}").merchant_id || "";
}
};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 calls the provider with the supplied amount and reports the outcome.
ctx.data.amount: Positive cents to refund (already validated against the remaining balance; may be less than the order total for a partial refund)ctx.data.currency: Order currencyctx.data.reason: Admin notectx.data.payment_id: The original charge/payment idctx.data.idempotency_key: Unique per attempt — pass it to the provider so a retry never refunds twice- Report: set
ctx.data.refund_id(the provider's refund id) andctx.data.status—"succeeded"(money returned) or"pending"(provider settles later and confirms viapayment.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. Refunds accumulate on the order: once the refunded amount reaches the total the order moves to refunded, otherwise partially_refunded.
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';
}
};The admin refund API is POST /admin/api/v1/orders/{id}/refund with an optional body { "amount": 500, "reason": "...", "manual": false, "restock": false, "items": [...] } — an empty body refunds the full remaining balance.
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 contextA single plugin may declare several payment gateways (one type: "payment" script each, with distinct gateway_ids), so the gateway segment selects which script handles the event. There are two credential models, and webhook setup differs between them:
- Own-keys gateways (each merchant uses their own API keys, e.g. Stripe): give every merchant the per-shop URL
…/gateway/stripe/shop/{their_id}. The shop is in the URL, so its per-shop signing secret resolves and you only reportorder_id(read from event metadata you stamped at create-intent/refund time). - Connected-account gateways (one app connects many sellers, e.g. Square): register the single URL
…/gateway/squareonce at the provider's app. There's no shop in the URL, so at connect time your OAuth callback callssw.payments.linkAccount(merchant_id), and on each webhook the platform runs your bridgelesspayment.webhook_accountparser to pull the account id out of the body, looks up the linked shop, and runspayment.webhookin that shop's context.
Recurring subscriptions
The platform — not the gateway — owns subscriptions: the contract, the billing clock, dunning/retries, trials, proration and lifecycle all live in the platform. A gateway opts in to recurring billing by exposing just two primitives through the existing payment.create_intent hook — vault a payment method and charge it off-session — so there are no new gateway hooks to implement.
Declare the capability on the payment script in your manifest:
{ "path": "payment.js", "type": "payment", "gateway_id": "stripe", "recurring": true }"recurring": true tells the platform this gateway can vault + charge off-session; without it, a shop can't sell subscription products through the gateway (the Settings → Payments UI shows whether the active gateway supports subscriptions).
Merchants turn a product into a subscription in the product editor (product.subscription): a set of plans (key, label, interval of weekly|monthly|quarterly|yearly, an optional fixed price or discount %, and an optional first_cycle_discount %), plus optional trial_days and max_cycles (0 = unlimited), and a required flag for subscription-only products. 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.
The lifecycle, end to end:
- Signup. At checkout the platform calls
create_intentwithctx.data.payment.save_method = true(ortrial = trueto defer the first charge). Your hook charges (unless trialing) and vaults the method, returning an opaque reusable token onctx.data.payment.vault_token. The platform stores it encrypted on a new subscription contract and schedules the first renewal. (If the first charge settles asynchronously, return the token on thepayment.webhook"paid"event instead — see above.) - Renewal. A platform cron finds due subscriptions and, for each, builds a new order (the invoice) and calls
create_intentagain withctx.data.payment.off_session = trueand the storedvault_token. Your hook charges the vaulted method with no shopper present and returnsstatus. A"paid"renewal flows through the normal paid path (fulfillment, digital delivery,checkout.after_payment);"pending"is reconciled later by yourpayment.webhook. - Dunning. A
"failed"(or errored) renewal charge increments the failure count and reschedules a retry; after the retry cap the platform cancels the subscription. You don't implement any of this — just return the charge result. - Lifecycle. Customers cancel/pause/resume from their account page; admins view and cancel from the Subscriptions screen. Cancelling/pausing simply drops the contract from the renewal sweep — no gateway call is made (the vaulted method is just no longer charged).
The token is opaque to the platform — it is stored encrypted and only ever handed back to your create_intent on renewal, so put whatever you need in it (e.g. JSON.stringify({ customer, payment_method }) for Stripe). A gateway that ignores these flags simply doesn't support subscriptions; everything else (one-time charges, refunds, webhooks) is unchanged.
Search Provider Hooks
Plugins can replace the built-in product search engine entirely. When a search provider plugin is active, all product indexing and querying flows through your plugin instead of the default engine. This lets you integrate any external search service (e.g., Algolia, Typesense, Meilisearch).
To register a search provider, add a script entry to your manifest with "type": "search":
{
"path": "search.js",
"type": "search",
"search_id": "my-search",
"search_name": "My Search Engine",
"search_color": "#5468ff"
}search_id: A unique identifier for your search provider.search_name: The display name shown in the admin Settings → Search panel.search_color: A brand color used for the provider badge in the admin UI.
Your search script must export four hooks:
search.query
Perform a product search. The platform calls this whenever a customer searches or browses products on the storefront.
ctx.data.query: The search query stringctx.data.limit: Maximum number of results to returnctx.data.cursor: Pagination cursor (empty on first page)ctx.data.filters: Active facet filtersctx.data.facets: Requested facet fieldsctx.data.sort: Sort orderctx.data.price_min/ctx.data.price_max: Price range filtersctx.data.own_only: Boolean — restrict results to the current shop- Return/Modify: Set
ctx.data.products(array of{id, shop_id}),ctx.data.cursor(next page cursor or empty string),ctx.data.total_count, and optionallyctx.data.facets(map of facet name →[{value, count}]).
search.index
Index a batch of products. Called automatically whenever products are saved and during a full re-index.
ctx.data.products: Array of full product objects to index
search.remove
Remove products from the search index. Called when products are deleted or deactivated.
ctx.data.product_ids: Array of{id, shop_id}references to remove
search.drop
Clear the entire search index for the shop. Called before a full re-index so the index can be rebuilt from scratch.
Example: External Search Provider
module.exports = {
"search.query": function(ctx) {
const resp = fetch("https://api.my-search.com/search", {
method: "POST",
headers: { "Authorization": "Bearer {secret.SEARCH_API_KEY}" },
body: JSON.stringify({ query: ctx.data.query, limit: ctx.data.limit })
});
const results = resp.json();
ctx.data.products = results.hits.map((hit) => ({ id: hit.product_id, shop_id: hit.shop_id }));
ctx.data.cursor = results.next_cursor || "";
ctx.data.total_count = results.total;
},
"search.index": function(ctx) {
fetch("https://api.my-search.com/index", {
method: "POST",
headers: { "Authorization": "Bearer {secret.SEARCH_API_KEY}" },
body: JSON.stringify({ documents: ctx.data.products })
});
},
"search.remove": function(ctx) {
fetch("https://api.my-search.com/delete", {
method: "POST",
headers: { "Authorization": "Bearer {secret.SEARCH_API_KEY}" },
body: JSON.stringify({ ids: ctx.data.product_ids })
});
},
"search.drop": function(ctx) {
fetch("https://api.my-search.com/clear", {
method: "POST",
headers: { "Authorization": "Bearer {secret.SEARCH_API_KEY}" }
});
}
};Activating a Search Provider
- Install the plugin
- Go to Settings → Search in the admin panel
- Click "Set Active" on your provider
- Click "Re-index All Products" to populate your external index
Template Render Hooks
template.before_render
Inject variables into Liquid templates before the page is rendered. Because this runs before the rendering phase, you have access to all sw.* bridges (like querying products or database records).
Theme Placement Hooks (hook.*)
Themes often define hooks within their templates using the {% hook "name" %} tag. Any plugin that exports hook.<name> has its return value rendered into that slot, in declaration order — when multiple plugins listen to the same hook (e.g. hook.product_after_form), their returned strings are automatically concatenated together.
The default theme ships these named slots:
head_start/head_end— top / bottom of<head>on every page.head_endis the right place to load JS that updates DOM prices in real time.body_start/body_end— top / bottom of<body>on every page.product_after_price— fires on both the product card snippet and the PDP, immediately after the price block. Render a live-price element here to supplement or replace the static{{ product.price | money }}. If a product hasprice == 0, the theme hides the static price line entirely, so the hook's output becomes the sole price display.product_after_add_to_cart,product_after_tags,product_after_form,product_footer— PDP-only extension points.main_start/main_end,footer_content— sectional slots.checkout_payment— the payment-method area, used by payment gateway plugins to render their SDK widget. Rendered both at checkout and on the order page when an unpaid order is being paid (a customer completing a created order, or settling/updating the card on a past-due subscription). The same client contract applies in both places: render your element, and on thecheckout_pre_submitwindow event push a Promise toe.detail.promisesand set your collected token one.detail.meta(e.g.e.detail.meta.payment_token = …). The platform forwards thosemeta[*]fields to yourpayment.create_intent(on the order page via/create-payment-intent), so one element implementation covers first-time checkout, retrying a created order, and subscription card updates. Scope it to the right dataloaders: declare at least["checkout", "order-detail"]so the element renders on both the checkout and the order/subscription-payment pages; omittingorder-detailis why an element shows at checkout but not when paying an existing order.
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.
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.
Loading data for hook tags: Use template.before_render with a dataloaders filter 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 — and is how you safely surface a value that requires the full sw.* bridge (e.g. a publishable key fetched via sw.secrets.get()) into a render-phase hook.
// 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>`;
}
};Security Note: Template render hooks (hook.*) execute during the template rendering phase in a restricted environment. They do not have access to the full sw.* bridge (like sw.products or sw.secrets). They only have access to sw.liquid.render. If you need to safely render public API keys (like a Stripe publishable key) into client-side code, you must explicitly fetch the secret via sw.secrets.get() during the template.before_render phase and inject it into the context bindings.
Example: Related Products Plugin
This example demonstrates how to use both hooks together: template.before_render securely queries the database and injects data, while a theme hook uses sw.liquid.render to output a Liquid snippet.
module.exports = {
"template.before_render": function (ctx) {
const settings = ctx.settings || {};
if (!settings.enable_related) return;
const product = ctx.data.bindings.product;
if (!product) return;
// Note: we have full access to sw.products here
const res = sw.products.search({ query: `tag:"${product.tags[0]}"`, limit: 4 });
if (res && res.items) {
ctx.data.bindings.related_products = res.items.filter((p) => p.id !== product.id);
}
},
"hook.product_after_form": function (ctx) {
const product = ctx.data.bindings.product;
if (!product) return "";
// sw.liquid.render securely renders a snippet from your plugin directory
return sw.liquid.render("./snippets/related-recent.liquid", {
settings: ctx.settings,
related_products: ctx.data.bindings.related_products
});
}
};Email Hooks
Every transactional email the shop sends (order confirmations, shipping notices, password resets, the sw.email.notify* bridges, etc.) passes through two hooks in the email worker. They run server-side in the shop's context — so the full sw.* bridge surface is available, including sw.email.smtpSend.
email.before_render
Fires after the email's template and bindings are assembled but before Liquid renders them. Mutate ctx.data to change the outgoing mail.
ctx.data.to— recipient addressctx.data.subject— subject linectx.data.template— the raw Liquid template string about to be rendered; replace it to swap the whole layoutctx.data.bindings— the variable map passed to the template (merge in your own keys)ctx.data.template_name— which email this is (order_confirmation,shipping_notification,password_reset,welcome, …)
module.exports["email.before_render"] = function (ctx) {
// Only restyle order confirmations
if (ctx.data.template_name === "order_confirmation") {
ctx.data.subject = "🎉 " + ctx.data.subject;
ctx.data.template = "<h1>{{ shop.name }}</h1>{{ email_content }}";
ctx.data.bindings.promo = "Use SAVE10 on your next order!";
}
};On email.before_render, calling ctx.stop() cancels the email entirely (modifications you made to ctx.data still apply alongside a stop()).
email.send
Fires after rendering, immediately before the message is handed to the transport. This is the final manipulation point for the envelope — mutate any of ctx.data's to, cc, bcc, reply_to, from, from_name, subject, html, text. This is where you set a custom sender:
module.exports["email.send"] = function (ctx) {
ctx.data.from = "[email protected]";
ctx.data.from_name = ctx.settings.store_name;
ctx.data.reply_to = "[email protected]";
};A plugin can also deliver the mail itself and then call ctx.stop() to suppress the platform's built-in send — either through the shop's own mailbox with sw.email.smtpSend, or by calling a transactional mail HTTP API (SendGrid, Mailgun, Postmark, …) with fetch. {secret.KEY} is expanded in smtpSend credential fields and in fetch URLs/headers/string bodies, so your API key never appears in the script:
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
};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. 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).
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
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;
}
};Each entry's fields:
loc(required) — the page path. A relative path (/blog/x) is prefixed with the storefront's canonical base URL; an absolute URL (https://…) is used as-is. Entries with an emptylocare dropped.lastmod(optional) — last-modified date. Any valuenew Date()can parse is accepted; it's normalized toYYYY-MM-DD.changefreq(optional) —always,hourly,daily,weekly,monthly,yearly, ornever.priority(optional) —0.0–1.0(number or string).
- Return only entries, not XML. Core escapes and chunks; a plugin cannot emit raw
<urlset>markup. - Paginate the full set. The hook runs once per sitemap build, so walk all your records (the
do…whilecursor loop above) rather than returning a single page. - Caching. The sitemap is cached for 24h (bypassed in dev mode), so edits appear within a day in production. There is no realtime requirement — search engines refetch on their own schedule.
- Don't list non-crawlable URLs. Skip drafts, redirects, and raw/JSON endpoints; only include canonical, indexable HTML pages. Add a per-record "Include in sitemap" boolean if you want merchants to opt individual pages out.
Build product links with sw.products.url() (see Store Bridge) so sitemap entries match the URLs the theme renders.
robots.txt
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="
];
}
};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, removed, or upgraded — to provision and tear down external resources (a dedicated database, a webhook registration, third-party state, etc.). These 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 (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 dependencies, they are activated first, so every dependency's ownplugin.activatehas already completed by the time this runs.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.plugin.change_version— fires when the installed version is switched (e.g. a marketplace upgrade or downgrade), after the new version's files are in place. The handler that runs is the new version's, and it receives the previous version so you can run data migrations.ctx.dataaddsold_versionalongside the standardplugin_id/version(the new one):{ plugin_id, version, old_version }. It does not fire when the version is unchanged.
These hooks fire only on the actual state transition, so a settings-only update never re-runs them. They are best-effort and non-blocking: if your handler throws (or the plugin doesn't export the hook), the error is logged and the transition 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) {
// ctx.data = { plugin_id, version, old_version } — run migrations for the new version.
runMigrations(ctx.data.old_version, ctx.data.version);
}
};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.
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.
{
"scripts": [
{ "path": "api.js", "type": "route", "method": "GET", "route_path": "/my-endpoint" }
]
}Don't confuse route_path with the script-level routes array. A route_path (with type: "route" + method) registers an HTTP endpoint the browser can hit. 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 of its own.
ctx.request.method: HTTP method (e.g., "GET", "POST")ctx.request.url/ctx.request.path: Full URL and URL path (e.g., "/my-plugin/api")ctx.request.query: Query string parameters
Rendering Pages: You can return sw.liquid.render(templatePath, bindings) as the body to render a full HTML page. If the template path starts with ./ (e.g., "./my-page.liquid"), it resolves relative to your plugin's directory. If it doesn't (e.g., "cart.liquid"), it resolves against the active theme's files.
module.exports.fetch = function(ctx) {
const req = ctx.request;
// Handle GET request - Return a Liquid template render
if (req.method === "GET") {
return {
status: 200,
headers: { "Content-Type": "text/html" },
body: sw.liquid.render("./my_template.liquid", { name: "Plugin User", path: req.path })
};
}
// Handle POST request - Return JSON with custom headers
if (req.method === "POST") {
const payload = req.json();
return {
status: 201,
headers: { "Content-Type": "application/json", "X-Custom-Header": "Demo" },
body: { success: true, received: payload }
};
}
return { status: 405, body: "Method Not Allowed" };
};Reading the request body
ctx.request exposes a browser/Cloudflare-style body API. The buffering methods 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 — pipe a raw upload straight to storage:
// 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 APIFile 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?
}Stream large uploads to storage via f.body rather than calling f.bytes() — a big file is never held whole in memory. The buffering methods (text / arrayBuffer / json) and req.body read the body for non-multipart requests only; for multipart, read fields and files through formData().
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 an HttpOnly session cookie. There is no client-side SDK to learn: write server-side JavaScript + Liquid + fetch, exactly like a route plugin.
Manifest
A widget is declared in two places in manifest.json: a scripts[] entry with type: "widget" mapping the script to a widget id, and 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" } }
}
]
}id/name/icon/description— gallery card display.source.type—"internal"(this plugin renders it) or"external"(third-party URL; see below).source.script— for internal widgets, the path that matches thescripts[].pathdeclared above.permissions— display-only string list shown in the gallery. Enforcement happens via the existing per-pluginsw.*scoping; this field is documentation for the shop admin.config_defs— per-instance config UI schema, same shape assettings[](text, number, checkbox, select, color, textarea).defaults.width— one of"1/4","1/3","1/2","2/3","full".defaults.rows— initial height in grid rows (1 row ≈ 80px); the grid snaps widgets to row boundaries.defaults.config— initial config values applied when the widget is added.placement.dashboard— show in the dashboard gallery (default true).placement.page.menu— also expose as a full-page entry in the left sidebar. Group adjacent page widgets under a header viaplacement.page.group.roles/placement.page.roles— restrict to specific shop roles; same shape ascustom_records.roles.styles—""(default) applies the bundled classlesssw-widgetstylesheet to the iframe body;"none"opts out so the widget controls its own styling (the html/body reset still ships).
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.
// 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— stable per-widget identifier within the containing dashboard. Empty when rendered as a page. Use this forsw.storagekeys to isolate state per instance.ctx.widget.dashboard_id— id of the containing dashboard (only set for dashboard widgets).ctx.widget.page—truewhen rendering as a left-menu page. Per-instance config falls back todefaults.config.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 }.ctx.role— the viewer's built-in shop role (owner/admin/staff). Also underctx.widget.user.role.ctx.permissions— array of this plugin's declared permissions the viewer holds (de-namespaced). Also underctx.widget.user.permissions. Gate behavior on these server-side, e.g.ctx.permissions.includes('view_orders').ctx.widget.csrf— random CSRF token bound to the session cookie; inject into Liquid for any POST endpoint your widget exposes.ctx.widget.base— path-prefix shared by every URL under this widget instance (e.g./w/1/reviews/recent-reviews/42). The iframe CSP only allowsconnect-src 'self', so in-iframe AJAX must hit this prefix.ctx.widget.url(path)— same asbasebut as a function for JavaScript:ctx.widget.base + '/' + path. Not callable from Liquid.
Return values
- 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 it is returned as-is.
{ html, status?, headers? }— explicit HTML response with no wrapper. Use when your widget controls the full document.{ json, status?, headers? }— JSON response. Use for AJAX endpoints called from inside the iframe.
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 shell appliesclass="sw-widget"to<body>by default, so every semantic HTML element inherits the admin theme styling. Three opt-in utility classes are available:.sw-grid,.sw-row, and.sw-muted. Design tokens are exposed as CSS custom properties on.sw-widget(--sw-primary,--sw-bg,--sw-surface,--sw-text,--sw-border,--sw-radius,--sw-accent,--sw-success,--sw-warning,--sw-danger). Declare"styles": "none"to opt out (the html/body reset still applies).sw-widget.js— exposeswindow.sw.fetch(path, opts): prependsctx.widget.baseto non-absolute paths, attachesX-CSRF: ctx.widget.csrfonPOST/PUT/PATCH/DELETE, JSON-encodes plain-object bodies, and returns thePromise<Response>. TheX-CSRFheader is mandatory on state-changing requests — the dispatcher rejects a missing or mismatched token.window.sw.baseandwindow.sw.csrfare also exposed for non-fetch use.
<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>Sandbox helpers
The widget 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. sw-widget.js fills the gaps so you don't hand-roll workarounds:
- Forms can't break the page. A global capture-phase
submithandlerpreventDefaults every submission, so you never see the "Blocked form submission … sandboxed" error — noonsubmit="return false"boilerplate. data-sw-actionauto-submits a form throughsw.fetch. Add it (optionally withdata-sw-action-nameto set abody.actiondiscriminator) and a plain<form>+ submit button just works — fields are validated withreportValidity(), POSTed as JSON, and on success the iframe reloads (opt out withdata-sw-reload="false").sw:success/sw:errorCustomEvents are dispatched on the form for custom handling.sw.toast(message, type)shows an in-DOM toast (type:okdefault,error,warn).window.alertis shimmed to call it.await sw.confirm(message, opts)resolvestrue/falsefrom an in-DOM dialog (opts:okText,cancelText,danger) — the async replacement for the blockedconfirm().await sw.pickFile(opts)/await sw.pickRecord(opts)open the admin's native pickers from inside a widget (the iframe can't reach the shop's file library or records itself).sw.pickFile({ multiple, root })resolves to a file URL string (orstring[]), ornullif cancelled.sw.pickRecord({ model, multiple })resolves to{ id }(or{ ids: [...] }), ornull—modelisproduct,customer,order,coupon, orcustom:<type>.
<form data-sw-action="action" data-sw-action-name="save">
<input name="sku" required>
<button>Add</button> <!-- a normal submit button now works -->
</form>
<script>
async function chooseImage() {
const url = await sw.pickFile({ root: 'public' });
if (url) document.getElementById('img').value = url;
}
</script>Native alert/confirm/prompt are overridden to a safe default. Since confirm()/prompt() can't block synchronously without allow-modals, always use the async sw.confirm. form.reportValidity() is fine — it isn't a modal.
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 — no framework to bundle. 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 shares it:
sw-data='{"mode":"in","qty":0}'— declares a reactive state root.sw-model="qty"— two-way binds an input/select/textarea tostate.qty(numbers/checkboxes coerced).sw-show="mode == 'in'"— shows/hides by a truthy expression.sw-text="qty * price"/sw-html="…"— setstextContent/innerHTMLfrom an expression.sw-class="on: mode=='ship'; warn: qty<0"— toggles each named class by its expression.sw-on:click="qty = qty + 1"— runs a statement on the event (any DOM event aftersw-on:), then re-renders.$eventis 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>
<input type="number" sw-model="qty">
<p>Moving <span sw-text="qty"></span> units.</p>
</div>Expressions are plain JavaScript evaluated with the root's state object in scope. A bad expression is logged and skipped, never thrown. sw.state(el) returns the nearest root's state and sw.refresh(el) re-renders it. This is a deliberately tiny convenience layer, not a framework — for anything more advanced, the widget CSP allows 'unsafe-eval', so load Alpine/petite-vue/Vue from your template and use it directly.
Fragment swaps — replace a full 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: '…' }:
sw-get="?view=products"— issues a GET (default trigger:click).sw-post="action"— issues a POST; serializes the nearest<form>(orsw-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, ornone.sw-confirm="Delete this?"— gates the request behindsw.confirm()first.sw-push="?view=products"— updates the iframe URL after a successful swap (no reload); on a page widget this also deep-links.
<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 come alive automatically. The attribute editor sw-attrs="fieldName" turns a container into an add/remove key/value editor that serializes to a hidden <input> holding a JSON object (seed with sw-attrs-value='{…}').
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. 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. Whenever your widget changes its location — a real navigation, 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 and replays it on refresh or a shared link so the widget reopens at the exact same place. You get this for free by changing your widget's URL; the platform never parses your URL scheme. Dashboard-card widgets don't deep-link.
Sandbox and security
- The iframe is served from a dedicated
WIDGET_ORIGIN(a separate hostname from the admin), withsandbox="allow-scripts allow-same-origin". The cross-origin boundary protects admin cookies and localStorage;allow-same-originis required for self-AJAX and is only safe because the widget origin differs from the admin origin. - CSP on every widget response:
default-src 'self'; connect-src 'self'; frame-ancestors {admin_origin}— the iframe can onlyfetchback to its own endpoint. - The HttpOnly
widget_sessioncookie is path-scoped to one(shop, plugin, widget, instance)tuple, so one widget cannot impersonate another even on the same origin. - The launch token sealing the session is a 5-minute HMAC; the session cookie is a 30-minute HMAC. Both signed with the standard
JWT_SECRET. - The widget script runs as the plugin itself with the same
sw.*scoping as any other plugin script — 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 (the hover-toolbar/page-header ↻) and reloads, and listens for config-changed (a full reload by default; define window.__widget_reload = function(newConfig) { ... } to handle config changes without a reload).
External widgets
A plugin can declare a widget rendered by a third-party service with source.type: "external":
{
"id": "acme-analytics",
"name": "Acme Analytics",
"source": {
"type": "external",
"url": "https://widgets.acme.com/embed",
"origin": "https://widgets.acme.com"
},
"placement": { "dashboard": true }
}The admin renders <iframe src="https://widgets.acme.com/embed?launch={signed_token}">. The third-party backend exchanges the launch token for the shop/user/instance claims by calling POST https://app.shopswired.com/api/widget-external/verify with { "token": "..." }. The response contains { shop_id, user_id, plugin_id, widget_id, dashboard_id, widget_key, page, expires_at, shop, user } if valid. The third-party service then issues its own session for its iframe AJAX — no admin auth crosses the boundary.
Actions and Cron Jobs (Run)
Any script that exports a run function can be triggered manually from the admin UI. To run a script automatically on a schedule, add a schedule (cron expression) to its entry in the manifest.
module.exports.run = function(ctx) {
// Do the work synchronously inside run().
console.log("Running scheduled job...");
};Run the body directly in run. If you need to fan out concurrent work, use sw.task.run and sw.task.join so every spawned task completes before run returns — an un-joined sw.task.run may be cut short when the request ends. For durable fire-and-forget work that must outlive the request, use sw.task.bg instead.
Custom Liquid Filters
Export functions with a filter. prefix to create Liquid filters. They act as pure value transforms and can be invoked directly in your theme templates.
module.exports = {
"filter.uppercase": function(value) {
return String(value).toUpperCase();
},
"filter.reading_time": function(value) {
const words = String(value || "").split(/\s+/).length;
return Math.ceil(words / 200) + " min read";
}
};Usage in Liquid template:
<h1>{{ product.name | uppercase }}</h1>
<p>{{ article.content | reading_time }}</p>4. API Bridges Reference
Plugins can access system resources via the global sw object and fetch.
Automatic Bridge Rate Limit
The platform automatically meters costly sw.* calls so a runaway loop can't hammer the data store. You don't opt in — it's always on. Each costly call spends weighted units, and the weights mirror real datastore pricing (a write costs ~3× a read; a delete less than a read):
| Class | Calls | Cost |
|---|---|---|
| Write | *.save, storage.set, ledger.credit/debit/compact, files.upload | 3 units × item count |
| Delete | *.delete, storage.delete, files.delete | 1 unit × item count |
| Read | *.get (× item count); files.read/download, storage.get | 1 unit |
| Query | *.list, *.getBySlug, products.search, ledger.balance/history/list/sum, files.list | 5 units |
| Free | sw.cache, sw.crypto, sw.csv, sw.sql (your own DB), fetch, sw.secrets, sw.notify, 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 datastore writes — batching saves the round-trips and CPU, not the per-entity datastore cost.
How the limit works — two layers:
- Local bucket. A token bucket, shared across your shop's concurrent runs, refilling at a per-second rate with a burst capacity. Set generously so normal burst work never trips it — it's an abnormal-activity trip-wire, not a quota. It adds no latency and stops a runaway loop within the current run.
Plan Burst Sustained (units/sec) Pro 8,000 500 Business 20,000 1,000 Enterprise 30,000 2,000 - Circuit breaker (fleet-wide). A trip is treated as a failure of the offending script, on the same breaker that catches crash-loops: the script's next run is refused fleet-wide, starting at about a minute and doubling if it keeps tripping (capped at 1h), and self-healing once the script behaves. It's keyed to the script's code, so it stops that script everywhere without touching your shop's other plugins.
When you're over budget the call throws bridge rate limit exceeded: too many costly sw.* calls; slow down or batch your operations — a normal, catchable error (it also surfaces in the admin log viewer). Key points:
- Batch, and spread bulk work out. Prefer one
save([...])over a per-row loop (one round-trip, less CPU). For large imports, page across requests (orsw.task.continue()) rather than draining your whole burst at once. - Reads are cheap, writes are dear, queries cost a flat 5 — mirror that in your loops; cache hot reads via
sw.cache(free). - Trip it and your script gets benched for a while, fleet-wide. A clean run clears it — so fix the loop, don't just retry.
HTTP & Network (fetch)
Standard browser-like Fetch API.
fetch(url: String, options?: Object) → FetchResponse
const response = fetch("https://api.example.com/data", { method: "POST", body: "{}" });
console.log(response.status, response.ok);
const data = response.json(); // or response.text()Secrets Bridge (sw.secrets) & Expansion
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} (see Expansion below), where it is expanded at the HTTP/crypto boundary and never materialises as plaintext.
sw.secrets.set(key: String, value: String, readable?: Boolean) → void
Stores a secret. Write-only by default; pass true as the third argument to mark it readable.
sw.secrets.get(key: String) → String
Returns the plaintext value of a readable secret, or "" for a missing or write-only secret.
sw.secrets.has(key: String) → Boolean
sw.secrets.delete(key: String) → void
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 injected into a template binding for the client SDK. 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 (a webhook signing secret does not need readable because sw.crypto.createHmac expands the placeholder for you).
sw.secrets.set("MY_PUBLISHABLE_KEY", "pk_live_...", true); // readable
const key = sw.secrets.get("MY_PUBLISHABLE_KEY"); // → "pk_live_..."Secret Expansion: You can embed secrets directly inside strings passed to bridges like fetch, sw.sql, sw.crypto, or sw.gdrive using the {secret.KEY} syntax. The backend expands them under the hood, keeping the raw values out of your memory state:
// The backend swaps {secret.STRIPE_KEY} with the real value automatically
const res = fetch("https://api.stripe.com/v1/charges", {
headers: { "Authorization": "Bearer {secret.STRIPE_KEY}" }
});Store Bridge (sw.products, sw.orders, sw.customers, sw.coupons, sw.records)
Manage database entities. Every store bridge exposes the same read/write API, so a plugin can save, get, list, and delete orders, products, customers, coupons, and custom records alike.
sw.[entity].save(data: Object) → Object
Creates or updates a record. Returns the saved object with its id.
sw.[entity].get(id: Number | String | Array<Number | String>) → Object | Array<Object>
Retrieves a single record or a batch of records by ID. IDs are numbers for most entities; sw.coupons uses the coupon code (String).
sw.[entity].list(options: Object) → { items: Array<Object>, cursor: String }
Queries records. options can include filters (Object), limit (Number),
order (String — a field name to sort by), and cursor (String). Filter keys match by
equality; append a comparison operator to a field name for range queries (see
Filter Operators & Range Queries below).
Document Store Limitations: The database is a document store. A single query may filter by equality on any number of fields, but a range (inequality) on only one field. See Filter Operators & Range Queries below.
sw.[entity].delete(id: Number | String) → void
Deletes the record by ID (the coupon code for sw.coupons).
sw.products.search(options: Object) → { items: Array<Object>, cursor: String, facets: Object, facet_labels: Object }
Full-text search specifically for products. options can include query (String), limit (Number), cursor (String), filters (Object), facets (Array<String>), and ownOnly (Boolean).
sw.products.url(product: Object) → String
Returns the canonical storefront URL for a product using the active theme's product route (e.g. "/product/widget/123", or the clean "/product/summer-sale" when the product has a custom slug). Accepts a product object or any { id | product_id, name, slug, shop_id } shape, and returns "" if there's no id. This 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. 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).
const url = sw.products.url(product); // "/product/widget/123"
const url2 = sw.products.url({ id: 42, name: "Gold Bar", shop_id: 1 });sw.customers.startImpersonation(customerId: Number) → { token, url }
Mints a stateless, 5-minute customer session on the acting staff member's behalf — the building block for a plugin "Log in as customer" flow (build a cart or place an order as the shopper). url is the storefront /impersonate/start?token=… link that logs the rep in as the customer and lands them on /account; orders placed in that session are stamped with order.created_by_user_id = the rep, so commission/attribution works automatically. Requires "impersonation": true in the manifest and an admin/widget context (an acting user must be on record) — it throws otherwise, and is unavailable from storefront routes and hooks. Every call is audited (IMPERSONATE_START) and rate-limited to 30/min per plugin; the session is deliberately short (1 hour). Gate your own "Login As" UI on a permission you declare (e.g. impersonate) and check via ctx.permissions. See the Sales-rep recipe for the end-to-end flow.
// widgets/login-as.js — gated on ctx.permissions
module.exports.fetch = function (ctx) {
if ((ctx.permissions || []).indexOf("impersonate") === -1) return { html: "Not authorized" };
const customerId = Number(ctx.request.query.customer_id);
const { url } = sw.customers.startImpersonation(customerId);
return { html: `<a class="sw-btn" href="${url}">Log in as customer</a>` };
};// Example: Product Search
const results = sw.products.search({ query: "running shoes", limit: 10 });
console.log(results.items.length);
// Example: Custom Records (Basic Equality)
const r = sw.records.demo_record.save({ title: "Hello", value: 123 });
const list = sw.records.demo_record.list({ filters: { value: 123 }, limit: 10 });
sw.records.demo_record.delete(r.id);
// Example: Coupons — id is the coupon code you assign (not generated)
const coupon = sw.coupons.save({
id: "SUMMER1", // the coupon code; saving over an existing id overwrites it
type: "percent",
value: 1500, // 15.00% (basis points)
min_order: 5000, // requires a $50+ order
max_uses: 100,
end: nextWeek, // expiry timestamp
tags: ["demo"],
active: true,
featured: true
});
const activeCoupons = sw.coupons.list({ filters: { active: true }, limit: 20 });
sw.coupons.delete("SUMMER1");Coupon IDs are the coupon code. Unlike other entities, you assign id yourself (e.g. "SUMMER1") instead of it being generated — saving with an existing id overwrites that coupon, and you pass the code to get / delete.
For range queries and ordering, see Filter Operators & Range Queries below.
Attaching Plugin Data to Built-in Records (.meta)
Every built-in record — orders, customers, coupons, products, and the like — carries a free-form .meta object your plugin can write to. Use it to stamp plugin-specific data directly onto the record it belongs to, instead of keeping a parallel custom record keyed by the built-in's id.
// In a before-save hook, mutating ctx.data persists with that same save:
module.exports["order.before_save"] = function (ctx) {
const order = ctx.data;
order.meta = order.meta || {};
order.meta.gift_wrap = true;
order.meta.source_campaign = "spring";
// no explicit save needed — it's written as part of this save
};
// Anywhere else (route, widget, scheduled run), write it back explicitly:
const c = sw.customers.get(customerId);
c.meta = c.meta || {};
c.meta.loyalty_tier = "gold";
sw.customers.save(c); // persist the change
// Read it back later:
const order = sw.orders.get(orderId);
const wrap = order.meta && order.meta.gift_wrap;.meta is opaque storage — it persists with the record but is not queryable. You can read it once you have the record, but you cannot filter or look records up by a .meta value in list(). So:
- Reach for
.metawhen the data belongs to one built-in record and you'll always load that record first anyway — a flag, a snapshot, an external id, a small bag of attributes. It travels with the record and needs no extra storage to declare. - Reach for a custom record (declared in
manifest.jsonundercustom_records) 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.
One rule on persistence: .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.
Filter Operators & Range Queries
All store-bridge list({ filters }) calls — sw.products, sw.orders, sw.customers, sw.coupons, and sw.records.<type> — support range filters by appending a comparison operator to the field name. A plain key means equality; the supported suffixes are >, >=, <, and <=.
// 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 } });One inequality field per query. Because it is a document store, a single query may use equality on as many fields as you like, but a range on only one field. Two bounds on the same field are fine ("price>=": 100, "price<": 500), but ranges on two different fields throw ("price>": 100, "stock<": 5). When you genuinely need two ranges, apply the more selective one in the query and filter the second in memory.
Ordering. If you don't pass an explicit order, the inequality field automatically becomes the primary sort.
Composite custom-record indexes
A custom-record schema can declare a composite index field whose index property lists several fields in order; the platform stores it as a single #-joined string. Reference it in filters by the composite field's name and pass an array value — the bridge joins the parts with # for you.
// manifest.json — inside custom_records[].fields[]
{ "name": "comp", "index": ["status", "user_id", "created"] }A plain array is an exact-equality match on the joined string: ["Approved", 1] matches exactly "Approved#1" and nothing more.
Range suffix. Operators work on a composite field. The canonical pattern is an equality prefix plus a range suffix — e.g. approved reviews by user 1 created on or after a cutoff:
sw.records.review.list({
filters: { "comp>=": ["Approved", 1, "2026-01-01T00:00:00Z"] }
});The array collapses to "Approved#1#2026-01-01T00:00:00Z" and is compared lexically — correct here because the trailing component is a sortable ISO timestamp. (Numeric components must likewise be stored in a lexically-sortable form, e.g. zero-padded.) For a bounded range, pass two filters on the same composite field ("comp>=" plus "comp<").
Prefix match with *. Since a plain array is exact equality, use * to match a prefix. Where you put it matters: as its own array element, * matches anything from the next # boundary onward; glued to a value, it is a prefix within that field.
// 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_id 10, 11, 100, …
sw.records.review.list({ filters: { comp: ["Approved", "1*"] } });
// → range ["Approved#1", "Approved#1")The # separator is the field boundary: place * as its own element to anchor on a boundary, and glue it to a value only for a genuine mid-field prefix (for example, slugs starting with "hello-" via ["hello-*"]).
Template Bridge (sw.liquid.render)
Render Liquid templates server-side. Typically used in fetch route handlers to return full HTML pages.
sw.liquid.render(templatePath: String, bindings: Object) → String
If templatePath starts with ./, it renders a file from the plugin's directory. Otherwise, it renders a file from the shop's active theme. The bindings object is exposed to the Liquid template.
const html = sw.liquid.render("./custom-page.liquid", { title: "Hello World" });Assets Bridge (sw.assets)
Generate CDN-backed, versioned URLs for any path. Use this in your plugin hooks (e.g. hook.* theme placements) so URLs are served from the CDN and automatically cache-busted whenever the plugin changes—avoid hardcoding /plugin-assets/... paths directly.
sw.assets.url(path: String) → String
Returns a versioned CDN URL for the given path.
module.exports = {
"hook.product_after_form": function (ctx) {
// Reference a bundled asset with proper CDN host + version hash
return `<script src="${sw.assets.url("js/widget.js")}"></script>`;
}
};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(key: String, value: Any) → void
sw.storage.get(key: String) → Any
sw.storage.delete(key: String) → void
sw.storage.list(options: Object) → { items: Array<{ key, value }>, cursor?: String }
Options: prefix (String), limit (Number), cursor (String).
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; 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 is 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.
sw.payments.linkAccount(accountId: String) → void — bind the provider account id to the current shop (call from the OAuth callback).sw.payments.unlinkAccount(accountId: String) → void — remove the link on disconnect.
// OAuth callback (runs in the connecting shop's context):
sw.payments.linkAccount(merchant_id); // bind merchant_id -> this shop
// sw.payments.unlinkAccount(merchant_id); // on disconnect
// payment.webhook_account — bridgeless parser, no sw.* available; just read body:
ctx.data.account_id = JSON.parse(ctx.data.body || "{}").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: String, status: String) → void
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 bypassing those side effects. It throws if no order carries that refund id. (Available in any shop context, not just marketplace plugins.)
// 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');
}Cache Bridge (sw.cache)
Distributed short-term key-value memory store.
sw.cache.set(key: String, value: Any, ttlSeconds?: Number) → void
Sets a value. Default TTL is 300 seconds (5 minutes).
sw.cache.get(key: String) → Any
sw.cache.delete(key: String) → void
Files Bridge (sw.files)
Upload and manage files.
sw.files.upload(path: String, content: String | FetchResponse | (sink) => void, contentType?: String) → FileObject
sw.files.read(path: String) → String — the whole file as a string (the quick "read my config" call).
sw.files.download(path: String) → Readable — the same lazy readable as fetch: { body, text(), json(), bytes() }.
sw.files.list(prefix: String) → Array<FileObject>
sw.files.delete(path: String) → void
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. Passing a string/bytes builds the whole file in memory first. Pass a callback instead and upload hands you a sink that streams each chunk straight to storage — memory stays bounded to one chunk no matter how large the file:
const res = sw.files.upload("exports/report.csv", (sink) => {
sink.write("id,name\n"); // each write() is copied straight to the destination
sink.write("1,Acme\n");
}, "text/csv"); // -> { path, url, public_url?, size }sink.write(data)accepts a string or bytes and returns the number of bytes written.- Lifecycle is scoped to the callback — there's nothing to close. The upload finalizes when the callback returns, and aborts (the whole
uploadthrows) if the callback throws, so a failure never persists a partial file as a success. - Pair it with
sw.csv.writerto stream row batches (see CSV bridge below).
Reading files. sw.files.read(path) returns the whole file as a string. For streaming, JSON, or large files, sw.files.download(path) returns the same lazy readable as fetch:
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.
Task Bridge (sw.task)
Execute heavy background operations concurrently without blocking.
sw.task.run(func: Function, ...args) → Task
sw.task.join(...tasks: Task[]) → Array<Any>
const t1 = sw.task.run((url) => fetch(url).json(), "https://api.example.com/data");
const [result] = sw.task.join(t1);Durable Background Tasks (sw.task.bg)
sw.task.run runs in-process 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.
sw.task.bg(func: Function, ...args) → String
Persists the closure and runs it later in a separate worker VM. Returns an opaque task id string; it cannot be join()-ed. Like sw.task.run, closures lose their outer scope — pass external values as arguments. The closure receives a ctx object as its first argument, followed by the saved positional args.
module.exports = {
"order.after_save": function (ctx) {
// Return immediately; the heavy work runs in the background.
sw.task.bg((ctx, orderId) => {
const order = sw.orders.get(orderId);
fetch("https://erp.example.com/sync", { method: "POST", body: JSON.stringify(order) });
}, ctx.data.id);
}
};- Durable: the task is persisted before it runs, so it executes even after the triggering request finishes and survives restarts.
- Fire-and-forget: returns a task id string and cannot be
join()-ed. When you need the result inline, usesw.task.run+sw.task.joininstead. - Per-plan concurrency: each shop runs at most a tier-based number of background tasks at once — Pro: 1, Business: 5, Enterprise: 20. Tasks beyond the cap are queued and start automatically as running ones finish.
- Full bridge access: each task runs in its own VM with all
swbridges, and may itself enqueue moresw.task.bgwork. - 10-minute limit + continuation: each task is subject to the standard 10-minute execution limit; call
sw.task.continue(data)to resume in a fresh task while keeping its concurrency slot across the chain. - Anti-runaway limits:
sw.task.bgthrows if a chain of tasks spawning tasks exceeds 100 generations, or if a shop enqueues more than its per-minute budget (Pro: 120, Business: 600, Enterprise: 2400). Errors are recorded in the admin log viewer.
A bg closure's ctx (its first argument) 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.
// 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
}
}
});For a worked example of using scheduled and event hooks together to recover failed or abandoned orders, see the Recipes cookbook.
Task Continuation
Use sw.task.continue() for long-running batch jobs to bypass the 10-minute VM timeout. It
re-enqueues the current script and immediately halts the VM.
sw.task.continue(data?: Object) → void
if (ctx.timeoutRemaining() < 30000) {
sw.task.continue({ cursor: nextCursor });
}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, default 200, are held in memory at a time — the same cursor model 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 keeps the read side streaming in chunks; the upload sink buffers the write side. So writing one row at a time stays efficient no matter how large the export.)
SQL Bridge (sw.sql)
Connect to your own external database — currently MySQL and Turso/libSQL (remote-only; no local/SQLite). Connections are pooled per shop and reused across runs; you do not open or close them — the engine manages the pool with idle cleanup.
// 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 row-by-row while
// only one batch is held in memory — 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 pooled 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 is atomic, so concurrent writers never lose 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
// (sw.http, 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 dimensionCompaction. The account holds the authoritative balance, so entries are only an audit trail. Prune them to keep storage bounded — the balance is never touched:
sw.ledger.compact("points", "cust_9", { keep: 3 }); // → { removed }
// keeps the newest 3 entries + one "snapshot" entry carrying the balance forward
// Or auto-compact: configure a book once; an account self-prunes to `keep` once
// its entry count reaches `maxEntries` (maxEntries must be greater than keep).
sw.ledger.config("points", { keep: 50, maxEntries: 200 });Amounts are integers only (use minor units like cents for money). A 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.
Email Bridge (sw.email)
Send emails to shop staff or customers.
Transactional use only: sw.email is intended for transactional and operational messages — order confirmations, shipping updates, account notices, password resets, and staff alerts. Bulk, mass, promotional, newsletter, or marketing sending through this bridge is not permitted (see our Terms of Service § Email & Communications and § Fair Use). For mass mailing, integrate a dedicated third-party email service provider via a plugin using fetch, so sending runs on that provider's own infrastructure.
To alert shop staff, use the sw.notify bridge — its email channel honors each staff member's per-category preferences. To email a customer, use sw.email.notifyCustomer.
sw.email.notifyCustomer(options: Object) → void
Options: customer_id (Number), subject, template, html, text, data.
sw.email.smtpSend(options: Object) → void
Deliver a fully-formed email through a caller-supplied SMTP server (e.g. the shop's own mailbox), synchronously. Options: host, port (defaults to 587), username, password, secure ("tls"/"ssl" for implicit TLS, typical for port 465; "starttls" to require an upgrade; omit to opportunistically STARTTLS), from, fromName, replyTo, to, cc, bcc, subject, html, text. The recipient fields 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 host, username, password, from, fromName, and replyTo fields support {secret.KEY} expansion, 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.
Notify Bridge (sw.notify)
Posts entries into the shop's admin notification tray — the bell + dropdown in the admin sidebar, backed by a per-shop notifications page. Read/unread state is shop-level (shared across the shop's staff). Use this for operational alerts a merchant should see: "sync finished", "low stock", "action needed". For end-customer messages use sw.email instead — this tray is staff-facing only.
Declare your categories first. A plugin can only post notifications under a category it lists in its manifest notify_categories. Each declared category becomes its own opt-in/out row (grouped under your plugin) in every staff member's Account → Notifications — so a merchant can mute "low stock" from your plugin while keeping "sync failed". A plugin that declares none cannot call sw.notify.create.
// manifest.json
"notify_categories": [
{ "key": "sync", "label": "Inventory sync" },
{ "key": "errors", "label": "Sync errors" }
]// Create a notification. title and category are required.
const { id } = sw.notify.create({
title: "Inventory sync finished",
category: "sync", // REQUIRED — must be one of your manifest notify_categories keys
body: "412 products updated, 3 skipped.", // optional one-liner
severity: "success", // info | success | warning | error (default "info")
link: sw.widget.url("sync-status"), // admin path to open when clicked (see sw.widget below)
email: false, // optional: false → in-app only (default: deliver per each recipient's prefs)
dedupeKey: "sync-2024-06-01" // optional idempotency (see below)
});
// List this plugin's OWN notifications (never platform or other plugins' entries).
const { notifications, cursor } = sw.notify.list({ limit: 20 /*, cursor */ });
// Dismiss one of this plugin's own notifications (no-op if it isn't yours or doesn't exist).
sw.notify.dismiss(id); // id as a number, or a string for large ids (precision-safe)- Category is manifest-bound.
categorymust be one of your declarednotify_categorieskeys; a blank or undeclared category throws. The stored category is namespaced to your plugin, so it never collides with another plugin's or a core category. (Thelabelis what staff see in their preferences; it defaults to a humanized key.) - Source is enforced.
sourceis always set to your plugin id — a plugin can't impersonate the platform or another plugin, andlist/dismissonly ever touch your own entries. - Idempotency. A non-empty
dedupeKeymakescreatea no-op for ~10 minutes if the same key was already used (returns{ id: 0 }). Use it so retries/re-runs don't stack duplicates. - Rate limits. Creation is capped at ~60/min per plugin and ~300/min per shop; over the limit,
createthrows. The tray is low-volume by design — don't use it for high-frequency events. - Realtime. New entries push a live badge update to any open admin tab (best-effort; the UI also polls), so you don't manage delivery.
- Email delivery (preferences-driven). Besides the in-app tray, a notification can also be emailed to the shop's staff — but the plugin does not choose who. Each staff member controls, under Account → Notifications, which categories email them per shop. Owners/admins get your plugin's categories on by default (and can untoggle each one); other staff opt in.
createhonours those preferences automatically. Passemail: falseto suppress email entirely (in-app only); you cannot force an email past a user's opt-out (anti-spam). This is the preferred way to alert staff.
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" });Google Drive Bridge (sw.gdrive)
Interact with Google Drive using a Service Account JSON credential.
sw.gdrive.init(credentialsJSON: String) → void
Initializes the service. Call this before using list or download.
sw.gdrive.list(folderId: String, callback: Function, options?: Object) → void
Paginates through a folder, invoking the callback with { id, name, size, mimeType } for each file. Options: query, pageSize.
sw.gdrive.download(fileId: String) → FetchResponse
Returns a response-like object containing raw file bytes. The result can be passed directly to sw.files.upload().