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)

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 into order.Meta. Any <input name="meta[KEY]"> posted with the checkout form lands at order.meta.KEY with zero backend code (checkout scans the posted form for meta[…] keys). This is the whole mechanism for approach A.
  • checkout.before_create (Plugins.md) receives { order, cart, shop } and can mutate order.meta. Limitation: its ctx has 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 for order.after_save.
  • Route handlers (type: "route") do get the full request — ctx.request has headers (incl. Referer, User-Agent), query, body, and ctx.customer_id. This is the only seam that can capture attribution server-side, and a route response can set a first-party cookie via FetchResponse.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, '&quot;') + '">';
  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

NeedUse
Quick marketing tags on the order, trust the clientA (meta[*])
Queryable/reportable attribution as its own entityB (custom record)
Server-verified referrer/IP, fraud-sensitive payout routingC (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 only owner/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_adjustment for 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.

  1. Opt in + declare a permission in your manifest. The impersonation flag 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." }
      ]
    }
    
  2. Gate on the permission, then mint and redirect. In a widget, check ctx.permissions, call sw.customers.startImpersonation(customerId), and send the rep to the returned url:

    // 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.Cart is writable via sw.customers.save, and getCart returns it for a logged-in buyer.

The cart field 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
  }]
});

price is an integer in the shop's minor units (e.g. cents), matching how products store price — not a decimal. set keys/values are the variant option names the product defines. Storefront re-validation still applies at checkout, so a stale price/name snapshot 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 never eval() 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_end that polls that route every few seconds and re-renders each price element emitted by product_after_price. Write textContent, never innerHTML, 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 (or null) quote.
  • Price twicecart.calculate_prices for the live display while shopping, checkout.before_create for the immutable snapshot. The cart hook alone wouldn't survive into the persisted order.
  • payment.before_intent is the enforcement seam — the engine knows nothing about price locks; the deadline lives on order.meta and 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.jsno 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 helprows × 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.bg has 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 one sw.task.bg that processes a chunk and calls sw.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 just curls it.
  • Files OUT → the image POSTs to the platform-injected SW_UPLOAD_URL to mint a signed upload URL per artifact, then PUTs the bytes straight to storage. Any number, any size — no request/response limit applies.
  • Result → the job is async; run() returns a jobId immediately and the container.job.completed hook 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": true in the manifest, and — for shop-paid jobs — the shop owner's Container jobs opt-in. If any is unmet, sw.container is simply absent (not an error you can catch on call). Always feature-detect !!sw.container and 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_TOKEN injected into every job — the only credential the image gets. No platform credentials, no sw.secrets, nothing else. Artifacts land in the installing shop's file manager under container-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.completed runs later with its own budget.
  • Feature-detect, don't catch — the three gates make sw.container absent, not throwing, so check !!sw.container up 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 } (only entity is required; see the reference for every field).
  • entity is 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.
  • mode is "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.entity identifies the record server-side; sw.entity is the client-side mirror ({ type, id }, or null off 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.typeLoad the record with
ordersw.orders.get(id)
productsw.products.get(id)
customersw.customers.get(id)
couponsw.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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 at widgets/foo.js doing require("./lib/x") loads <plugin>/lib/x.js, not <plugin>/widgets/lib/x.js. Put shared widget code in a root lib/ (a widgets/lib/… path throws "Invalid module" at render). A require() 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). Use type="button" + sw-post / sw.fetch, and onsubmit="return false" on any <form> (see Plugins.md).
  • WCAG (AA) applies to widget-rendered markup — semantic elements, labels, AA contrast, keyboard operability.
  • fulfillment / subscription have no read bridge yet — a placement there still renders, but from ctx.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/debit retry on contention instead of losing updates, idemKey deduplicates re-fired events, transact moves several accounts atomically, and sum/list roll 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, and record.<kind>.* — see Data Hooks). ctx.data is the new state and ctx.old_data the 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: true for pure counters nobody audits; for audited books set auto-compaction once (sw.ledger.config(book, { keep, maxEntries })).
  • Don't sum a 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 same transact as the per-customer leg, and read that one balance instead.
  • transact touches 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.storage job. A nightly scheduled script that aggregates into a report object has a single writer — no race — so storing the finished snapshot in sw.storage (or a custom record, if merchants should browse it) is fine. The ledger is for numbers that many concurrent writers bump.