Recipes

A task-oriented cookbook for plugin and theme developers. Each recipe is an end-to-end "how do I…" that composes the primitives from the Plugin API Reference (bridges, hooks, route handlers, widgets) and the Theme API Reference (Liquid, storefront context, forms). The reference docs tell you what each piece does; these recipes show you how to wire them together for a goal — and, just as importantly, call out where the platform can't do something so you don't design into a dead end.


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 and 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 onto the order. Any <input name="meta[KEY]"> posted with the checkout form lands at order.meta.KEY with zero backend code. This is the whole mechanism for Approach A.
  • checkout.before_create 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, or 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 a Set-Cookie header.

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 and 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 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 (ctx) {
    const o = ctx.data, prev = ctx.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 (ctx) {
  const o = ctx.data; if (ctx.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 the platform providing the session-minting primitive.

1. Staff-built orders — POS, phone & RFQ quotes (native — no plugin)

The platform 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 or 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 the platform provides the one primitive a plugin needs: sw.customers.startImpersonation. The plugin owns the trigger and UI; the platform 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 (no server-side record) 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 snippet above 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 — a logged-in customer's cart is writable via sw.customers.save (set the cart field), and the storefront serves it back to that buyer on their next visit.


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 store a rule per product (a formula or a markup) and evaluate it 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.)

Store the per-product rule wherever the catalog editor can reach it — e.g. product.meta.spot_formula = "spot_gold * troy_oz + 50" and product.meta.troy_oz. 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),
// loads the product's formula from meta, evaluates it, 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: storefront render scripts can't use 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;
        if (!p || !p.meta || !p.meta.spot_formula) return "";
        // Server-render an initial price from the cached quote so non-JS visitors
        // still see a number; the poller then keeps it live.
        return `<span class="live-price" data-product="${p.id}"`
             + ` data-formula="${String(p.meta.spot_formula).replace(/"/g, "&quot;")}"`
             + ` data-troy-oz="${parseFloat(p.meta.troy_oz) || 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. Note it uses the optional cron variant to keep the cache warm; the lazy getSpot above is the leaner default — same hooks and client, just drop the cron and fetch on demand.


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 trips. (See the automatic 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 in its own VM/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, batch) => {                     // runs as a NEW request...
        sw.records.save("product", batch);          // ...with its own burst budget
    }, 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). 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. 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 are 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 (via ctx.old_data) so you only fire once:

module.exports = {
    "order.after_save": function (ctx) {
        const o = ctx.data, prev = ctx.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.
    }
};