Recipes
Task-oriented cookbook for plugin/theme developers. Each recipe is an end-to-end "how do I…" that composes the primitives documented in Plugins.md (bridges, hooks, route handlers, widgets) and Themes.md (Liquid, storefront context, forms). The reference docs define what each piece does; this file shows how to wire them together for a goal, and — just as importantly — calls out where the platform can't do something so you don't design into a dead end.
When a recipe depends on a bridge/hook signature, it links back to the canonical definition rather than restating it. If you change a surface a recipe relies on, update the recipe in the same change (same rule as Plugins.md / Themes.md).
Index
- Order attribution (UTM / landing page / referrer)
- Sales-rep / acting on a customer's behalf
- Live / spot pricing (bullion, FX, commodities)
- Importing a large CSV without tripping the rate limit
- Recovering failed / abandoned orders
- Offloading heavy work to a container job (OCR, media, ML)
- Adding a widget to an entity detail page (tab / button)
- Live counters & aggregates (counts, sums, averages)
Order attribution (UTM / landing page / referrer)
Goal: record where a buyer came from (landing page, referrer, utm_*, an ad
click id) and attach it to the order so reporting/payout can read it later.
Yes, this is possible — three ways, in increasing robustness. Pick by how much you trust the client and whether you need server-verified data.
What the platform gives you
- Checkout already lifts
meta[*]form fields intoorder.Meta. Any<input name="meta[KEY]">posted with the checkout form lands atorder.meta.KEYwith zero backend code (checkout scans the posted form formeta[…]keys). This is the whole mechanism for approach A. checkout.before_create(Plugins.md) receives{ order, cart, shop }and can mutateorder.meta. Limitation: itsctxhas no request object — no referrer, cookies, query string, or IP. So the hook can sanitize / enrich / validate attribution that already arrived (on a form field or a linked record), but it cannot itself read the landing URL. Same fororder.after_save.- Route handlers (
type: "route") do get the full request —ctx.requesthas headers (incl.Referer,User-Agent),query, body, andctx.customer_id. This is the only seam that can capture attribution server-side, and a route response can set a first-party cookie viaFetchResponse.Headers(Set-Cookie).
Approach A — hidden form fields → order.meta (simplest)
No plugin required. The theme captures attribution on first visit and replays it
into the checkout form. Stash first-touch values in localStorage so they
survive navigation:
<!-- In the theme's <head> or a global script, run once on landing -->
<script>
(function () {
if (!localStorage.getItem('sw_attr')) {
var p = new URLSearchParams(location.search);
localStorage.setItem('sw_attr', JSON.stringify({
landing: location.pathname + location.search,
referrer: document.referrer || '',
utm_source: p.get('utm_source') || '',
utm_medium: p.get('utm_medium') || '',
utm_campaign: p.get('utm_campaign') || '',
gclid: p.get('gclid') || '', fbclid: p.get('fbclid') || ''
}));
}
})();
</script>
<!-- In checkout.liquid, inside the checkout <form>, before submit -->
<span id="attr-fields"></span>
<script>
(function () {
var a = JSON.parse(localStorage.getItem('sw_attr') || '{}'), html = '';
for (var k in a) if (a[k]) html +=
'<input type="hidden" name="meta[' + k + ']" value="' + a[k].replace(/"/g, '"') + '">';
document.getElementById('attr-fields').innerHTML = html;
})();
</script>
Result: order.meta.utm_source, order.meta.landing, etc., readable in the
admin, on the receipt template ({{ order.meta.utm_source }}), and from any
plugin via sw.orders.get(id).meta.
Limitation — it's client-supplied. The buyer (or a bot) can post anything in
meta[*]. Fine for marketing analytics; don't drive money decisions
(commission, payout routing) off unverified values without a sanity check. Add a
checkout.before_create guard to whitelist/normalize keys if it matters:
module.exports = {
"checkout.before_create": function (ctx) {
const m = ctx.data.order.meta || (ctx.data.order.meta = {});
const allowed = ["utm_source", "utm_medium", "utm_campaign", "gclid", "fbclid", "landing", "referrer"];
for (const k of Object.keys(m)) if (!allowed.includes(k)) delete m[k]; // drop junk
if (m.utm_source) m.utm_source = String(m.utm_source).slice(0, 64); // bound length
}
};
Approach B — a custom record, not order.meta
Use this when you want attribution as its own queryable entity (one row per
order, reportable, exportable) instead of loose keys on the order. Declare a
custom record in the manifest (see Custom Records)
and write it from order.after_save on the create transition:
// manifest.json declares custom record kind "attribution" with fields:
// order_id, customer_id, utm_source, utm_medium, utm_campaign, click_id, landing, referrer
module.exports = {
"order.after_save": function (event) {
const o = event.data, prev = event.old_data;
if (prev) return; // only on first create
const m = o.meta || {};
sw.records.attribution.save({
order_id: o.id, customer_id: o.customer && o.customer.id,
utm_source: m.utm_source || "", utm_medium: m.utm_medium || "",
utm_campaign: m.utm_campaign || "", click_id: m.gclid || m.fbclid || "",
landing: m.landing || "", referrer: m.referrer || ""
});
}
};
The attribution still arrives via Approach A's hidden fields (the hook can't
read the request); approach B just relocates the destination so you can
sw.records.attribution.list({ filters: { utm_campaign: "spring" } }) for
reporting without scanning every order.
Approach C — server-captured beacon (most robust)
When you need attribution the client can't forge — a true server-read Referer,
User-Agent, or first-touch timestamp — capture it in a route handler. The
browser owns the visitor id (it has crypto.randomUUID() and can set its own
first-party cookie); the server's value-add is persisting the request facts the
client can't fake.
<!-- Theme: assign a stable visitor id and beacon once on landing -->
<script>
(function () {
var vid = (document.cookie.match(/sw_vid=([^;]+)/) || [])[1];
if (!vid) { vid = crypto.randomUUID();
document.cookie = "sw_vid=" + vid + "; Path=/; Max-Age=2592000; SameSite=Lax"; }
if (!sessionStorage.getItem('sw_beaconed')) {
sessionStorage.setItem('sw_beaconed', '1');
var p = new URLSearchParams(location.search);
fetch('/attr/track', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visitor_id: vid, landing: location.pathname + location.search,
utm_source: p.get('utm_source') || '', utm_campaign: p.get('utm_campaign') || '' }) });
}
})();
</script>
// manifest.json: { "path": "track.js", "type": "route", "method": "POST", "route_path": "/attr/track" }
module.exports.fetch = function (ctx) {
const req = ctx.request, body = req.json() || {};
if (!body.visitor_id) return { status: 400, body: { error: "visitor_id required" } };
sw.records.attribution.save({
visitor_id: body.visitor_id,
customer_id: ctx.customer_id || 0,
referrer: req.headers["Referer"] || "", // server-read, not client-claimed
user_agent: req.headers["User-Agent"] || "",
landing: body.landing || "",
utm_source: body.utm_source || "", utm_campaign: body.utm_campaign || ""
});
return { status: 204 };
};
Then carry the visitor id into checkout as meta[visitor_id] (Approach A's
mechanism), and link order → attribution record on order.after_save:
module.exports["order.after_save"] = function (event) {
const o = event.data; if (event.old_data) return;
const vid = o.meta && o.meta.visitor_id; if (!vid) return;
const rec = sw.records.attribution.list({ filters: { visitor_id: vid }, limit: 1 }).items[0];
if (rec) sw.records.attribution.save({ id: rec.id, order_id: o.id }); // stamp the order onto it
};
Limitations of C: it costs an extra request (the beacon) on landing, and a
logged-out → logged-in stitch still relies on the sw_vid cookie surviving until
checkout. The id is client-generated (it's only a correlation key, not a
secret); what's trustworthy is the server-read Referer/User-Agent the handler
records against it.
Which to use
| Need | Use |
|---|---|
| Quick marketing tags on the order, trust the client | A (meta[*]) |
| Queryable/reportable attribution as its own entity | B (custom record) |
| Server-verified referrer/IP, fraud-sensitive payout routing | C (beacon + link) |
Sales-rep / acting on a customer's behalf
Goal: let a sales rep take orders at the counter or over the phone, build orders for customers, and — where you genuinely need it — act as the customer, restricted so the rep can't touch settings, refund, or delete.
There are two different things here: the first is fully native; the second is plugin-driven, with core providing the session-minting primitive.
1. Staff-built orders — POS, phone & RFQ quotes (native — no plugin)
Core already lets a scoped staff member compose a manual order for a customer — the same primitive behind in-person POS, phone/MOTO orders, and B2B RFQ quotes:
Mint a role. In Settings → Users → Roles create a Sales Rep role with the capabilities you want — e.g.
orders: write,customers: write,products: read,reports: read(no full/delete, no settings). Built-in roles are onlyowner/admin/staff; everything more specific is a custom role.The admin order builder (
orders: write) is the surface: the rep picks/creates the customer and places a manual order (order.manual = true). A manual order reserves no stock until it's paid, and an offline or cash sale is settled simply by marking it paid — the POS path — with no online gateway involved.Attribution is automatic — every such order is stamped with
order.created_by_user_id(indexed; shown as a "Created by {rep}" badge), so a plugin can commission/notify and you can report on it:module.exports["order.after_save"] = function (ctx) { const o = ctx.data; if (ctx.old_data || !o.created_by_user_id) return; // new, rep-placed order sw.ledger.credit("rep:" + o.created_by_user_id, Math.round(o.totals.subtotal * 0.05)); };Pricing is safe — wired-supplier floors are re-derived at payment time, so a rep can't sell below margin; use a coupon or
payment.calculate_adjustmentfor a rep discount, not hand-edited prices.
This is plain staff selling, not logging in as the shopper.
2. True impersonation (becoming the customer) — plugin-driven
Logging a rep in as a customer is not a built-in feature, but core provides
the one primitive a plugin needs: sw.customers.startImpersonation. The plugin
owns the trigger and UI; core mints the session and handles attribution.
Opt in + declare a permission in your manifest. The
impersonationflag is the platform gate (and shows in the plugin's status panel); the permission gates your "Login As" UI:{ "impersonation": true, "permissions": [ { "key": "impersonate", "label": "Sign in as a customer", "description": "Act as a shopper to place/troubleshoot orders." } ] }Gate on the permission, then mint and redirect. In a widget, check
ctx.permissions, callsw.customers.startImpersonation(customerId), and send the rep to the returnedurl:// widgets/login-as.js 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); // hand the rep the link (render it, email it, or redirect) return { html: `<a class="sw-btn" href="${url}">Log in as customer</a>` }; };
startImpersonation returns { token, url }: a stateless 5-minute token
carrying the customer id, shop id, and the acting rep's id. The url points at the storefront /impersonate/start?token=… route, which
validates the token, swaps it for a 1-hour customer session cookie carrying the
rep id, and lands the rep on /account — now shopping as the customer. Orders
placed in that session are stamped with order.created_by_user_id = the rep, so
the commission recipe in Orders attributes them automatically.
Notes: the call must run in an admin/widget context (a staff user must be on
record) — it's unavailable from storefront routes/hooks; it throws unless the
manifest sets "impersonation": true; it's audited (IMPERSONATE_START) and
rate-limited (30/min per plugin). The session is deliberately short (1h) — it's an
assisted session, not a normal login.
Pre-staging a customer's cart is also possible without impersonating —
Customer.Cartis writable viasw.customers.save, andgetCartreturns it for a logged-in buyer.The
cartfield is an array of line items. Each item:sw.customers.save({ id: customerId, cart: [{ product_id: 123, // required — the product shop_id: shop.id, // required — owning shop (use this shop unless wiring a cross-shop product) qty: 2, // required — quantity name: "Blue Shirt", // display name (snapshot) price: 1999, // unit price in cents/minor units image: "https://…/s.jpg", // thumbnail URL set: { Size: "M", Color: "Blue" }, // selected variant options (optional) plan: "" // subscription plan key, or "" for a one-time purchase }] });
priceis an integer in the shop's minor units (e.g. cents), matching how products store price — not a decimal.setkeys/values are the variant option names the product defines. Storefront re-validation still applies at checkout, so a staleprice/namesnapshot won't override the real product on order.
Live / spot pricing (bullion, FX, commodities)
Goal: sell products whose price tracks a fast-moving external number — a precious-metals spot price, an FX rate, a commodity index — so the catalog, cart, and checkout all reflect the live quote, displayed prices update without a reload, and the price the customer pays is the one in effect when they pay.
The trick is to never derive a product's stored price; instead identify a
live-priced product by its attributes (e.g. a hidden Metal + Troy Oz
attribute) and evaluate one shop-wide formula against a live quote at
every pricing moment. The quote is fetched lazily and cached for ~1 minute —
so the upstream spot service is hit at most once a minute no matter how many
shoppers are on, and zero times when nobody is shopping. No cron needed: you
only pull the quote on the actions that matter (showing a price, adding to cart,
checking out), and a warm cache serves everything in between.
1. Fetch the quote lazily, cached ~1 minute (no cron)
A single helper is the only thing that touches the upstream API. It returns the
cached quote when warm; on a miss it fetches once, caches for 60s, and fails
soft — if the fetch dies and there's nothing cached it returns null, and
callers leave the price alone rather than charging $0.
// shared by the pricing hooks AND the /spot route below — one cache, one fetch.
function getSpot(settings) {
let quote = sw.cache.get("spot");
if (quote) return quote; // warm — no upstream call
try {
quote = fetch(settings.spot_api_url).json(); // { gold, silver, ... }
} catch (e) {
console.error("spot fetch failed (no cached quote): " + e);
return null;
}
quote.ts = Date.now();
sw.cache.set("spot", quote, 60); // ~1-minute window
return quote;
}
The first add-to-cart (or price view) in any 60-second window pays one in-request
fetch; everything else that minute — cart re-renders, the polling ticker, the
checkout snapshot — reuses the cache. (If you'd rather never make a shopper wait
on the first fetch, a */1 cron calling getSpot to pre-warm the cache is an
optional add-on — but it's not required, and it's wasteful on a quiet store.)
Drive the rule from hidden product attributes rather than a custom editor:
tag each product with a Metal attribute (gold/silver/…) and a Troy Oz
weight (and optionally a Premium), all { extra: { hidden: true } } so they
stay out of the storefront's Details table, and keep the formula itself in
plugin settings (one formula for the whole shop, e.g.
spot * troy_oz + premium). Variant-level attributes of the same name override
the product's for that variant. Reading config from attributes means no bespoke
admin UI — the merchant just edits the product. Keep the catalog price at 0:
it's meaningless for a live-priced item, and the theme hides the static price
line when price == 0 (the live element from step 5 becomes the only price
shown).
2. Price every line server-side
Evaluate the rule against the quote in the cart and checkout hooks (see
Checkout & Cart Hooks). cart.calculate_prices
fires on add-to-cart and every cart render; checkout.before_create bakes the
final snapshot into the order so the persisted line prices can't drift
mid-checkout. Both go through the same cached getSpot — so a burst of cart
activity still pulls the upstream service at most once a minute.
// hooks.js — priceCents(productId, set, qty, settings) calls getSpot(settings),
// reads the product's Metal/Troy Oz/Premium attributes, evaluates the shop-wide
// formula, and returns cents (or null for non-live products / no quote). Round
// to whole cents — money is integer.
module.exports = {
"cart.calculate_prices": function (ctx) {
for (const item of (ctx.data.items || [])) {
const px = priceCents(item.product_id, item.set, item.qty, ctx.settings);
if (px !== null) item.price = px; // engine reads this back
}
},
"checkout.before_create": function (ctx) {
const order = ctx.data.order;
let live = false;
for (const it of (order.items || [])) {
const px = priceCents(it.product_id, it.set, it.qty, ctx.settings);
if (px !== null) { it.price = px; live = true; }
}
if (live) { // stamp a price-lock deadline
order.meta = order.meta || {};
order.meta.price_lock_expires = Date.now() + (ctx.settings.lock_minutes || 10) * 60000;
}
},
};
Evaluating the rule: render scripts have no
require(), and you should nevereval()merchant-entered text. Use a tiny safe expression evaluator (a ~60-line recursive-descent parser over+ - * / ()and named variables) or, simplest, skip formulas entirely and store a flat markup (price = spot * troy_oz + meta.premium_cents). Whatever you pick, the server copy (hooks) and any browser copy (step 5) must agree — keep them in sync.
3. Lock the price through payment
A snapshot is only honest if it can't go stale between "place order" and "pay".
payment.before_intent runs right before any gateway call (initial checkout
and the retry path); throw to block it, with redirect_url to bounce the
buyer back to refresh.
"payment.before_intent": function (ctx) {
const exp = ctx.data.order.meta && ctx.data.order.meta.price_lock_expires;
if (exp && Date.now() > exp) {
throw { error: "Spot prices changed since checkout — please review your cart.",
redirect_url: "/cart" };
}
},
4. (Optional) payment-method surcharge / discount
Bullion shops often surcharge cards and discount wires. payment.calculate_adjustment
pushes +/- lines that compose with other plugins' adjustments — compute against
order.totals.subtotal, not the grand total:
"payment.calculate_adjustment": function (ctx) {
const m = (ctx.data.payment_method || "").toLowerCase();
let pct = 0, label = "";
if (/card|credit|stripe/.test(m)) { pct = ctx.settings.cc_surcharge_pct; label = "Card surcharge"; }
else if (/wire|ach|bank/.test(m)) { pct = -ctx.settings.wire_discount_pct; label = "Wire discount"; }
if (!pct) return;
const amount = Math.round((ctx.data.order.totals.subtotal || 0) * pct / 100);
ctx.data.adjustments = (ctx.data.adjustments || []).concat([{ label: `${label} (${pct}%)`, amount }]);
},
5. Live storefront display (no reload)
Two pieces, both on hook slots the default theme already ships (see Template Render Hooks) so no theme edits are needed:
- A route that serves the quote as JSON via the same cached
getSpot— so a polling client hits the upstream service at most once a minute (and is what warms the cache on a quiet store, replacing the cron). The browser never talks to the spot provider directly, so the API key stays server-side. - A bit of ticker JS loaded in
head_endthat polls that route every few seconds and re-renders each price element emitted byproduct_after_price. WritetextContent, neverinnerHTML, so a bad formula can't inject script.
// api.js — { "path": "api.js", "type": "route", "route_path": "/api/plugins/<id>/spot", "method": "GET" }
module.exports.fetch = function (ctx) {
const spot = getSpot(ctx.settings); // lazy + 1-min cache (same helper as the hooks)
return spot
? { status: 200, headers: { "Cache-Control": "no-store" }, body: spot }
: { status: 503, body: { error: "spot unavailable" } };
};
// render.js — emit the live element; the poller (loaded via head_end) keeps it fresh.
module.exports = {
"hook.head_end": function (ctx) {
// Seed from cache if warm; the poller fetches /spot on load to fill/refresh.
return `<script>window.__spot=${JSON.stringify(sw.cache.get("spot") || {})}</script>` +
`<script src="${sw.assets.url("js/live-price.js")}"></script>`;
},
"hook.product_after_price": function (ctx) {
const p = ctx.data.bindings.product;
const metal = (p.attrs || []).find(a => a.name === "Metal");
if (!metal) return ""; // not a live-priced product
const troyOz = (p.attrs || []).find(a => a.name === "Troy Oz");
// The formula is shop-wide (shipped once in head_end); the element only
// carries the per-product inputs. Server-render an initial price from the
// cached quote so non-JS visitors see a number; the poller keeps it live.
return `<span class="live-price" data-product="${p.id}"`
+ ` data-metal="${metal.value}"`
+ ` data-troy-oz="${troyOz ? parseFloat(troyOz.value) || 0 : 0}">…</span>`;
},
};
Why it's shaped this way
- Lazy fetch + 1-min cache, not a cron — pull the quote only on the actions
that need it (price view, add-to-cart, checkout, the polling route), all behind
one cached
getSpot. Upstream is hit ≤ once/minute under any load and not at all when the store is idle — strictly cheaper than a per-minute cron, which pays even at 3am with nobody shopping. One helper is also the single place to fail soft to the last-known-good (ornull) quote. - Price twice —
cart.calculate_pricesfor the live display while shopping,checkout.before_createfor the immutable snapshot. The cart hook alone wouldn't survive into the persisted order. payment.before_intentis the enforcement seam — the engine knows nothing about price locks; the deadline lives onorder.metaand this hook honors it.- Round to integer cents everywhere, and treat a missing/zero quote as "don't
price this line" (return
null) rather than charging$0.
A fuller worked implementation (variant-level weights, a shared formula grammar,
the polling client) ships as the bundled Bullion / Spot Pricing plugin
(cli/plugins/bullion-pricing). It's attribute-driven with no per-product
editor: products are tagged with hidden Metal / Troy Oz / Premium
attributes (names configurable in settings) and one formula in settings prices
them all — so a merchant configures pricing by editing the product, not a bespoke
admin page. It's built around the lazy getSpot above (lib/spot.js, required
by the hooks and the /spot route) with the attribute/formula logic factored
into lib/bullion.js — no cron at all: the quote is fetched on demand and
the /spot poll warms the cache on a quiet store. It also hardens the
checkout.before_create hook: a live-priced line that can't be quoted right now
blocks checkout (throw … redirect_url:"/cart") instead of persisting the
catalog's $0 placeholder. The demo plugin's create_bullion_samples.js
(▶ Run) seeds a ready-to-price set of coins and bars for it.
Importing a large CSV without tripping the rate limit
Burst is a per-run budget: a chunk of writes that all execute in one request
spends against one bucket, so reading the file in chunks within a single
handler does not help — rows × 3 is charged in one go and a file past
~burst / 3 rows (≈1,000 on Free, ≈2,666 on Pro) trips. (See the
platform bridge rate limit for
where these numbers come from.) The fix is to split the work across requests,
because each sw.task.bg closure runs as its own isolated request and therefore gets
its own fresh burst budget. Parse the CSV in the handler (sw.csv is free),
and hand each chunk to a background task that does the saves:
// Handler / route: stream the CSV cheaply, fan chunks out to bg tasks.
const cur = sw.csv.reader(sw.files.download("import.csv").body, { header: true });
let chunk = [], row;
const flush = () => {
if (!chunk.length) return;
sw.task.bg((ctx) => { // runs as a NEW request...
sw.records.save("product", ctx.args); // ...with its own burst budget
}, { args: chunk });
chunk = [];
};
while ((row = cur.next()) !== null) {
chunk.push(row);
if (chunk.length === 500) flush(); // 500 × 3 = 1,500 units/task
}
flush(); // trailing partial chunk
Each task spends only chunkSize × 3 against its own bucket, so size the chunk
to stay under the smallest burst you target (≤ ~500 rows keeps a task at 1,500
units — safe even on Free). Two limits to respect:
- Chunk size ≤ burst. Keep one task's writes under a single burst; don't rely on mid-task refill.
sw.task.bghas a per-minute enqueue cap (Pro 120 / Business 600 / Enterprise 2400). One-task-per-chunk is fine for thousands of rows, but for very large files (tens of thousands of rows) don't enqueue thousands of tasks — instead run onesw.task.bgthat processes a chunk and callssw.task.continue({ offset })to walk the file. That keeps a single concurrency slot, each continuation is a fresh request with a fresh burst, and it sidesteps the enqueue cap entirely (see Task Continuation).
Recovering failed / abandoned orders
Core never sweeps or deletes orders whose payment didn't go through — a decline
(sync) or settlement failure (async) lands the order in payment_failed, and an
abandoned checkout stays created. They're left in place on purpose so a plugin
can act on them: payment-retry nudges, win-back campaigns, retargeting, or its
own cleanup policy.
React in real time from order.after_save, watching for the transition so you
only fire once:
module.exports = {
"order.after_save": function (event) {
const o = event.data, prev = event.old_data;
if (o.status === "payment_failed" && prev && prev.status !== "payment_failed") {
if (o.customer && o.customer.email) {
sw.email.notifyCustomer({
customer_id: o.customer.id,
subject: "Your payment didn't go through",
template: "./emails/payment-retry.liquid",
data: { order: o }
});
}
}
}
};
Or sweep on a schedule (a run script) to follow up on stale unpaid orders —
order.status is indexed, so filtering is cheap:
module.exports.run = function (ctx) {
const res = sw.orders.list({ filters: { status: "created" }, limit: 100 });
const hourAgo = Date.now() - 60 * 60 * 1000;
for (const o of res.items) {
if (new Date(o.created).getTime() > hourAgo) continue; // still fresh
// send an abandoned-checkout reminder, tag the customer, etc.
}
};
Offloading heavy work to a container job (OCR, media, ML)
Goal: run work that doesn't fit a request or a sw.task.bg closure — OCR, image/video transcoding, ML inference, PDF/Pandoc, a scrape or a build — using an arbitrary container image (native binaries, big toolchains), feed it a file, and collect the artifacts it produces, all from a plugin.
Yes — that's exactly what sw.container is for. Each job runs in its own ephemeral, isolated environment, so you can run arbitrary native code. The shape that matters is the data flow, because the platform never streams bytes through your script in either direction:
- File IN → a short-lived signed download URL (
sw.files.signedUrl) handed to the image as an env var; the image justcurls it. - Files OUT → the image
POSTs to the platform-injectedSW_UPLOAD_URLto mint a signed upload URL per artifact, thenPUTs the bytes straight to storage. Any number, any size — no request/response limit applies. - Result → the job is async;
run()returns ajobIdimmediately and thecontainer.job.completedhook fires in a fresh request when it finishes. Never loop waiting inside one script — the platform stops running it once the response is sent.
The bundled demo plugin's Image OCR widget (cli/plugins/demo/widgets/ocr.{js,liquid}, hooks.js, image in cli/plugins/demo/ocr-image/) is the end-to-end worked example; this recipe is its skeleton.
What the platform gives you
Three availability gates (all required): a paid shop plan,
"containers": truein the manifest, and — for shop-paid jobs — the shop owner's Container jobs opt-in. If any is unmet,sw.containeris simply absent (not an error you can catch on call). Always feature-detect!!sw.containerand explain, rather than calling blind:if (!sw.container) { return { status: 400, json: { error: "Container jobs need a paid plan, the manifest \"containers\" flag, and the " + "shop owner to enable Container jobs in settings." } }; }SW_UPLOAD_URL+SW_TOKENinjected into every job — the only credential the image gets. No platform credentials, nosw.secrets, nothing else. Artifacts land in the installing shop's file manager undercontainer-jobs/<jobId>/…and count against that shop's quota (even when the developer pays for compute).Live logs for ~7 days via
sw.container.logs(jobId)(or the admin Logs viewer) — including for failed/auto-destroyed jobs, so a bad input URL or non-zero exit stays debuggable after the fact.
1. Launch the job (server side — a widget/route handler)
Mint a signed download URL for the input file and pass it as an env var. run() is non-blocking.
// widgets/ocr.js — POST { file } : start an OCR job for a picked Files image.
module.exports.fetch = function (ctx) {
if (ctx.request.method !== "POST") {
return sw.liquid.render("./widgets/ocr.liquid",
{ ctx, container_available: !!sw.container }); // template explains if absent
}
if (!sw.container) return { status: 400, json: { error: "Container jobs unavailable." } };
// A file INTO the job: short-lived signed DOWNLOAD url, handed in as env.
// (A signed Files url isn't always reachable from inside the job, so also
// accept a public http(s) url as an alternate input mode.)
const inputUrl = sw.files.signedUrl(ctx.request.json().file, { ttl: 3600 });
const { jobId } = sw.container.run({
image: "alpine:3.20", // any public image; pre-bake tools for faster startup
cmd: ["sh", "-c", OCR_CMD], // see step 2 — the in-image pipeline
env: { INPUT_URL: inputUrl },
size: "small", // small | standard | performance
timeoutMs: 5 * 60 * 1000, // ≤ your plan's max job timeout
});
return { json: { jobId } }; // hand the id to the client; it listens on the bus
};
2. The image: download input → work → upload artifacts
The image curls $INPUT_URL, does the work, then for each output POSTs {path,contentType} to $SW_UPLOAD_URL and PUTs the bytes to the returned signed URL. Send the exact Content-Type you requested or the upload rejects the signature.
set -e
curl -fsSL "$INPUT_URL" -o /tmp/in.img
tesseract /tmp/in.img /tmp/result # produces /tmp/result.txt
up() { # up <relative/path> <content-type> <local file>
u=$(curl -fsS -X POST "$SW_UPLOAD_URL" -H 'Content-Type: application/json' \
-d "{\"path\":\"$1\",\"contentType\":\"$2\"}" | jq -r .url)
curl -fsS -X PUT "$u" -H "Content-Type: $2" --data-binary "@$3"
}
up result.txt text/plain /tmp/result.txt
path is sanitized hard (no .., no escaping the job folder). For production, bake the tools into your own image instead of apk add-ing them at runtime.
3. React when it finishes (the hook — a fresh request)
container.job.completed fires for any terminal status (done/failed/canceled) after billing settles. List the job folder, sign each artifact, and notify / push to the live widget. Don't poll from a script.
// hooks.js
module.exports["container.job.completed"] = function (ctx) {
const d = ctx.data; // { job_id, status, exit_code, cost_cents, result_url }
let files = [];
if (d.status === "done") {
files = (sw.files.list(d.result_url || "tmp/container-jobs/" + d.job_id + "/") || [])
.map(f => ({ name: String(f.path).split("/").pop(),
url: sw.files.signedUrl(f.path, { ttl: 3600 }) }));
}
sw.notify.create({ // merchant alert — works even if no widget is open
title: d.status === "done" ? "OCR finished" : "OCR job " + d.status,
category: "demo", // must be declared in manifest notify_categories
body: d.status === "done" ? files.length + " file(s) ready" : "Job " + d.job_id + " " + d.status,
severity: d.status === "done" ? "info" : "warning",
});
sw.bus.emit("ocr:" + d.job_id, { status: d.status, files }); // live push to the open widget
};
The widget that launched the job is already listening on ocr:<jobId> (via sw.bus), so the result renders without polling. A container.job.completed job that you'd rather poll for can call sw.container.get(jobId) from a widget instead.
Who pays — and one gotcha
container_billing in the manifest picks the invoice: "shop" (default — installing shop's wallet) or "developer" (your publisher shop, so you can offer container features without the merchant funding compute; recoup it yourself via sw.ledger / IAP / a plan). Artifacts always belong to the installing shop regardless — compute is the payer's, files are the shop's.
Why it's shaped this way
- Signed URLs both directions, never our request pipe — a file in via a signed download URL, files out via
SW_UPLOAD_URL, so size limits never apply and the image holds exactly one narrowly-scoped token and no other credentials. - Async + hook, not a wait — the platform stops running your script once the response is sent, so a script cannot block on a job.
run()enqueues;container.job.completedruns later with its own budget. - Feature-detect, don't catch — the three gates make
sw.containerabsent, not throwing, so check!!sw.containerup front and tell the merchant which gate to flip. - Blocked inside
sw.ledger.transact— a container run has external side effects, so it can't run mid-ledger-transaction; charge the ledger after the job, typically from the completion hook.
Adding a widget to an entity detail page (tab / button)
Goal: surface a record-scoped widget on an entity's admin detail page — a tab in its tab bar, or an action button that opens in a modal — so an admin sees your UI in the context of one order / product / customer / coupon / fulfillment / subscription / custom record.
Yes — this is the detail-page widget placement
surface. The same fetch(ctx) widget script renders it; the only new thing it
sees is ctx.widget.entity = { type, id } — the server-trusted record the
page is bound to. You load that record through the matching sw.* bridge and
render record-specific UI. No new endpoint, no theme edit.
What the platform gives you
placement.detail[]in the widget manifest — one entry per place the widget appears. Each is{ entity, mode, label, icon, variant, size, roles }(onlyentityis required; see the reference for every field).entityis one of"order","product","customer","coupon","fulfillment","subscription", or a custom record type as"custom:<type>"(e.g."custom:wishlist"). The admin must have read access to that entity or the placement is hidden.modeis"tab"(default — a first-class, deep-linkable tab in the page's tab bar) or"button"(an action button that opens the widget in a host modal).ctx.widget.entityidentifies the record server-side;sw.entityis the client-side mirror ({ type, id }, ornulloff a detail page).sw.close({ refresh })(button mode) dismisses the modal and, by default, re-fetches the underlying record so the page reflects any change.
1. Declare the placement(s) in the manifest
A single widget can appear in several places — e.g. a tab and a button, on one
entity or many. Register the script once (type: "widget") and list placements:
{
"scripts": [
{ "path": "widgets/gift-view.js", "type": "widget", "widget_id": "gift-view" }
],
"widgets": [
{
"id": "gift-view",
"name": "Gift",
"icon": "🎁",
"source": { "type": "internal", "script": "widgets/gift-view.js" },
"placement": {
"dashboard": false,
"detail": [
{ "entity": "order", "mode": "tab", "label": "Gift" },
{ "entity": "order", "mode": "button", "label": "Gift", "icon": "🎁", "variant": "secondary", "size": "sm" }
]
}
}
]
}
2. Read the bound record in the script
ctx.widget.entity is server-trusted — never trust a client-supplied id here.
Branch on entity.type and load through the matching bridge:
entity.type | Load the record with |
|---|---|
order | sw.orders.get(id) |
product | sw.products.get(id) |
customer | sw.customers.get(id) |
coupon | sw.coupons.get(id) |
custom:<type> | sw.records.get("<type>", id) |
fulfillment, subscription | (no read bridge yet — render from ctx.widget.entity alone) |
// widgets/gift-view.js
module.exports.fetch = function (ctx) {
const ent = ctx.widget && ctx.widget.entity;
if (!ent || ent.type !== "order") return "<p>No order in context.</p>";
const order = sw.orders.get(ent.id) || {};
const meta = order.meta || {};
const msg = meta.gift_message ? String(meta.gift_message) : "";
// Button mode opens in a modal — give it a Close button (sw.close is a no-op
// in tab mode, so a shared script can include it harmlessly, but cleaner is to
// only render it where it makes sense). Use template literals for HTML.
return `
<section style="padding:1.25rem">
<h2 style="margin:0 0 .75rem">🎁 Gift</h2>
${msg
? `<blockquote>${msg.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]))}</blockquote>`
: `<p>Not marked as a gift.</p>`}
<div style="margin-top:1rem"><button type="button" onclick="sw.close({ refresh: false })">Close</button></div>
</section>`;
};
One widget on every entity (and across plugins)
To put the same widget on many entities, list one placement per entity. A custom
record type works exactly like the built-ins — and it does not have to be a
type your plugin owns: sw.records.get("<type>", id) reads any registered custom
record (subject to the viewer's records capability), so a generic "inspector"
widget can attach to another plugin's records too:
"detail": [
{ "entity": "order", "mode": "tab", "label": "Inspector", "icon": "🔬" },
{ "entity": "product", "mode": "tab", "label": "Inspector", "icon": "🔬" },
{ "entity": "customer", "mode": "tab", "label": "Inspector", "icon": "🔬" },
{ "entity": "coupon", "mode": "tab", "label": "Inspector", "icon": "🔬" },
{ "entity": "fulfillment", "mode": "tab", "label": "Inspector", "icon": "🔬" },
{ "entity": "subscription", "mode": "tab", "label": "Inspector", "icon": "🔬" },
{ "entity": "custom:demo_record", "mode": "tab", "label": "Inspector", "icon": "🔬" },
{ "entity": "custom:wishlist", "mode": "tab", "label": "Inspector", "icon": "🔬" }
]
One widget can serve a tab and a button for the same entity — list both
placements (as above) and branch on ctx.widget.placement ("tab" |
"button" | "dashboard" | "page") so the modal (button) render shows a
sw.close() button and the inline (tab) render doesn't:
const close = ctx.widget.placement === "button"
? `<button type="button" onclick="sw.close({ refresh: false })">Close</button>`
: "";
Limitations & gotchas
require()resolves from the plugin root, not the script's folder. A top-level widget script atwidgets/foo.jsdoingrequire("./lib/x")loads<plugin>/lib/x.js, not<plugin>/widgets/lib/x.js. Put shared widget code in a rootlib/(awidgets/lib/…path throws "Invalid module" at render). Arequire()failure throws while the module's top-level code runs — surfaced as a widget script error.- Widget forms never submit natively (the iframe is sandboxed without
allow-forms). Usetype="button"+sw-post/sw.fetch, andonsubmit="return false"on any<form>(see Plugins.md). - WCAG (AA) applies to widget-rendered markup — semantic elements, labels, AA contrast, keyboard operability.
fulfillment/subscriptionhave no read bridge yet — a placement there still renders, but fromctx.widget.entity(type/id) only until a bridge exists.
Worked example
The bundled demo plugin ships this end-to-end: one widget
(widgets/entity-inspector.js, require("./lib/entity-info") from a root-level
lib/) is placed as both a tab and a button on every entity type — including
custom:wishlist, a record the demo doesn't own — in cli/plugins/demo/manifest.json,
and its renderer branches on ctx.widget.placement. The single-entity worked
example is cli/plugins/gifting (a Gift button on the order page). See also
Detail-page plugin widgets.
Live counters & aggregates (counts, sums, averages)
Goal: answer "how many / how much" instantly — reviews per product, average rating, units sold, paid-order counts, points liability — without loading every record each time someone looks at the number.
The constraint: there is no COUNT()/SUM() query over shop data.
sw.*.list returns pages of records with no total, and search's
total_count is approximate. Scanning everything on each read gets slower and
more expensive as the shop grows, so a live aggregate must be maintained
incrementally: apply a delta whenever a record changes, and keep a manual
"recount" escape hatch for when the number drifts.
What the platform gives you
sw.ledger— transactional integer counters.credit/debitretry on contention instead of losing updates,idemKeydeduplicates re-fired events,transactmoves several accounts atomically, andsum/listroll up across account paths. This is the aggregate primitive.- CRUD hooks — every save/delete of a built-in entity or custom record fires
a hook (
product|order|customer|coupon|wired_fulfillment .before_save / .after_save / .before_delete / .after_delete, andrecord.<kind>.*— see Data Hooks).ctx.datais the new state andctx.old_datathe previous one (absent on create), so a handler can compute the exact delta. This covers every mutation source: admin edits, checkout, other plugins, imports. - Action scripts — a "Recount" button on the plugin's Status panel that runs a background script on demand.
sw.task.continue— lets the recount page through any number of records across the background time limit, checkpointing as it goes.
Why sw.ledger, not sw.storage
sw.storage.get → add 1 → sw.storage.set is a read-modify-write with no
locking: two orders placed in the same moment both read 41, both write 42,
and one sale vanishes — silently, which is the worst kind of wrong. Ledger
mutations run in a transaction, so concurrent writers serialize instead.
sw.storage still has a role around the aggregate: recount checkpoints,
"last recounted at" timestamps, or a cached report snapshot produced by a
single background writer. Just never use it as the counter that hooks race on.
Step 1 — count in realtime from after_save / after_delete
Worked example: review stats per product for a review custom record —
a count and a rating_sum per product (average = sum ÷ count, both integers).
// hooks.js
module.exports = {
"record.review.after_save": function (ctx) {
const now = ctx.data, prev = ctx.old_data;
const pid = String(now.product_id);
sw.ledger.transact((tx) => {
if (!prev) { // create
tx.credit("reviews", pid, "count", 1, { noLog: true });
tx.credit("reviews", pid, "rating_sum", now.rating, { noLog: true });
return;
}
if (String(prev.product_id) !== pid) { // moved between products
bump(tx, String(prev.product_id), -1, -prev.rating);
bump(tx, pid, 1, now.rating);
return;
}
bump(tx, pid, 0, now.rating - prev.rating); // rating edited (or no-op)
});
},
"record.review.after_delete": function (ctx) {
const pid = String(ctx.data.product_id);
sw.ledger.transact((tx) => bump(tx, pid, -1, -ctx.data.rating));
}
};
// Signed adjustment helper: ledger amounts are positive, the verb is the sign.
function bump(tx, pid, dCount, dSum) {
if (dCount > 0) tx.credit("reviews", pid, "count", dCount, { noLog: true });
if (dCount < 0) tx.debit ("reviews", pid, "count", -dCount, { noLog: true, allowNegative: true });
if (dSum > 0) tx.credit("reviews", pid, "rating_sum", dSum, { noLog: true });
if (dSum < 0) tx.debit ("reviews", pid, "rating_sum", -dSum, { noLog: true, allowNegative: true });
}
Reading it back — from a widget, a route, a Liquid filter, wherever:
const count = sw.ledger.balance("reviews", pid, "count");
const sum = sw.ledger.balance("reviews", pid, "rating_sum");
const avg = count ? Math.round((sum / count) * 10) / 10 : 0; // e.g. 4.3
sw.ledger.sum("reviews", { dimension: "count" }); // total reviews shop-wide
sw.ledger.list("reviews", { limit: 50 }); // per-product accounts, pageable
Counting a one-time transition (an order becoming paid) needs one more
guard: order.after_save fires again for every later edit — tracking numbers,
notes, refunds — and must not re-count. idemKey makes the credit a natural
no-op after the first time:
module.exports["order.after_save"] = function (ctx) {
if (ctx.data.payment.status !== "paid") return;
// Counted at most once per order, however many times after_save re-fires.
sw.ledger.credit("sales", "paid_orders", 1, { idemKey: `paid:${ctx.data.id}` });
};
Step 2 — a "Recount" action to heal drift
Drift is a when, not an if: an after_* handler that throws is logged but
the record's write stands (the delta is lost), records may predate the
plugin's install, and your own delta logic can have bugs. So ship the
recompute-from-scratch path as an admin button:
{ "scripts": [
{ "path": "recount.js", "type": "action", "label": "Recount review stats" }
] }
// recount.js — pages through every review, then reconciles each account by the
// difference between the recomputed total and the live balance. Adjustments are
// logged (no noLog) with a description, so drift leaves a visible audit trail.
module.exports.run = function (ctx) {
let { cursor = "", totals = {} } = ctx.continue?.data || {};
while (true) {
const page = sw.records.review.list({ cursor, limit: 200 });
for (const r of page.items) {
const t = totals[r.product_id] || (totals[r.product_id] = { count: 0, sum: 0 });
t.count += 1;
t.sum += r.rating;
}
if (!page.cursor) break;
cursor = page.cursor;
if (ctx.timeoutRemaining() < 30000) sw.task.continue({ cursor, totals });
}
// Zero out accounts whose reviews are all gone — they won't appear in totals.
let acc = sw.ledger.list("reviews", { limit: 100 });
while (true) {
for (const a of acc.items) {
const pid = a.path[0];
if (!totals[pid]) totals[pid] = { count: 0, sum: 0 };
}
if (!acc.cursor) break;
acc = sw.ledger.list("reviews", { cursor: acc.cursor, limit: 100 });
}
for (const pid of Object.keys(totals)) {
sw.ledger.transact((tx) => {
settle(tx, pid, "count", totals[pid].count);
settle(tx, pid, "rating_sum", totals[pid].sum);
});
}
console.log(`recounted ${Object.keys(totals).length} products`);
};
function settle(tx, pid, dim, expected) {
const diff = expected - tx.balance("reviews", pid, dim);
if (diff > 0) tx.credit("reviews", pid, dim, diff, { description: "recount adjustment" });
if (diff < 0) tx.debit ("reviews", pid, dim, -diff, { description: "recount adjustment", allowNegative: true });
}
The same script doubles as the initial backfill right after install/activation
(run it once from the Status panel — or trigger the same logic from a
plugin.activate handler via sw.task.bg).
Because it settles by difference inside a transaction, it composes with live
hook traffic: a review saved mid-recount lands via its hook either before or
after the settle, and the next recount squares away any record the scan raced
past. If exactness at a specific instant matters, run it in a quiet window.
Limitations & gotchas
- Integers only. Ledger balances are whole numbers — store cents, basis points, or rating units and derive the decimal at display time.
after_*failures don't roll back the write — that asymmetry is the main drift source and the reason the recount action isn't optional.- Keep entry storage bounded.
noLog: truefor pure counters nobody audits; for audited books set auto-compaction once (sw.ledger.config(book, { keep, maxEntries })). - Don't
suma huge account set on a hot path. For a shop-wide total over many accounts (points liability across all customers), maintain a roll-up account credited/debited in the sametransactas the per-customer leg, and read that one balance instead. transacttouches at most 25 distinct accounts, and side-effectful bridges are blocked inside it — reconcile in per-entity transactions like the recount above, not one giant one.- Snapshot reports are a
sw.storagejob. A nightly scheduled script that aggregates into a report object has a single writer — no race — so storing the finished snapshot insw.storage(or a custom record, if merchants should browse it) is fine. The ledger is for numbers that many concurrent writers bump.