← Back to Blog
Developers

What Is ShopScript? Server-Side JavaScript for Commerce

ShopScript is ShopsWired's server-side JavaScript runtime — a custom, synchronous JavaScript engine that runs every customization script on the platform: plugin hooks, HTTP route handlers, admin dashboard widgets, and scheduled jobs. You write modern JavaScript (ES2015+), push it, and it runs next to the store's data. There is no server to provision, no container to keep warm, and no webhook round-trip to your own infrastructure.

That last part is the whole point. On most platforms, "server-side JavaScript for ecommerce" means your server: you receive a webhook, authenticate back to the store's REST API, do the work, and hope the retry policy is kind. ShopScript collapses that loop. The code that reacts to an order save is running at the moment of the save, with direct access to the store through a set of bridges — and it can abort the write by throwing.

The four places your code runs

A plugin is a directory with a manifest.json and some .js files. What a script is depends on how it's declared in the manifest:

The first argument is always ctx. A data hook gets ctx.data (the entity being written) and ctx.old_data (its previous state, absent on create). Mutate ctx.data in a before_save and the change persists; throw and the write is rejected.

module.exports = {
    "product.before_save": function (ctx) {
        if (!ctx.data.sku) {
            ctx.data.sku = "AUTO-" + Date.now();
        }
        // To abort the save:
        // throw { error: "Missing SKU" };
    }
};

We've covered the event model in more depth in Ecommerce Event Hooks Explained. Here we're interested in the runtime underneath it.

The sw.* bridges

ShopScript has no node_modules — the engine ships no npm packages, and require() resolves only inside your own plugin directory. Instead, capability comes from bridges hanging off a global sw object:

Buffered response bodies are capped at 1 MB, but every readable exposes a .body stream you can pipe straight into sw.files.upload or sw.csv.reader with no size cap — so a 400 MB supplier export never lands in memory.

Getting off the request path: sw.task.bg

ShopScript is synchronous. There is no event loop to hide latency behind, which means the discipline is explicit: realtime hooks stay small, and anything slow moves to a background task.

sw.task.bg(fn, opts) enqueues a durable, fire-and-forget task. It's persisted before it runs, so it executes even after the triggering request has finished and survives restarts. It returns an opaque task id and can't be joined — when you need a result inline, use sw.task.run + sw.task.join instead.

module.exports = {
    "order.after_save": function (ctx) {
        const orderId = ctx.data.id;
        // Return immediately; the heavy work runs in the background.
        sw.task.bg((ctx) => {
            const order = sw.orders.get(ctx.args.orderId);
            fetch("https://erp.example.com/sync", {
                method: "POST",
                body: JSON.stringify(order)
            });
        }, { args: { orderId } });
    }
};

Two things trip people up. First, the closure captures nothing from the surrounding scope — pass values through opts.args and read them back on ctx.args, or require() what you need inside. Second, each task gets a 10-minute budget; for longer work, sw.task.continue(data) checkpoints and resumes in a fresh task, keeping its concurrency slot across the chain.

sw.task.bg((ctx) => {
    let cursor = ctx.continue?.data?.cursor || "";
    while (true) {
        const page = sw.products.list({ cursor, limit: 100 });
        // ... process page.items ...
        if (!page.cursor) break;          // done
        cursor = page.cursor;
        if (ctx.timeoutRemaining() < 30000) {
            sw.task.continue({ cursor }); // resume in a new task; halts here
        }
    }
});

A delayed continuation (sw.task.continue(data, { delay: 15000 })) releases its concurrency slot while it waits — which is why polling an external job with a delayed continuation beats sleeping to keep a run alive.

Scheduled jobs

Add a schedule to a script entry and it becomes a cron job. Merchants can also trigger it on demand from the plugin's Status panel, so write scheduled scripts to be idempotent and safe to run off-cadence.

{
    "scripts": [
        { "path": "hooks.js" },
        { "path": "cron.js", "schedule": "*/15 * * * *" }
    ]
}

Scheduled scripts and background tasks share the same 10-minute budget and the same ctx.continue checkpointing. They also receive ctx.shop — the store's name, currency, canonical URL, timezone and theme/auth settings — so a nightly job can build absolute links without guessing the merchant's domain.

The limits, stated plainly

Every script runs under a timeout matched to where it sits:

ctx.timeoutRemaining() tells you how many milliseconds are left, everywhere.

Then there's plan-based capacity, which is worth knowing before you design a sync job. Durable background tasks run at a per-shop concurrency of 1 on Pro, 5 on Business, 20 on Enterprise, with per-minute enqueue budgets of 120 / 600 / 2,400; anything over the concurrency cap queues and starts automatically. Scheduled scripts have a minimum interval of 15 minutes on Pro, 5 minutes on Business, and every minute on Enterprise.

Store operations are metered by cost rather than call count: a read costs 1 unit, a delete 1, a write 3 (per item — a 500-row save really is 500 writes), a query a flat 5, and sw.cache, sw.time, crypto, sw.jwt, sw.csv, sw.sql, fetch, sw.secrets, sw.notify and sw.task are free. Pro allows a burst of 8,000 units with 500 units/sec sustained; Business 20,000 / 1,000; Enterprise 30,000 / 2,000. Paginated calls are capped at 500 rows and throw if you ask for more — they never quietly return a short page, which is the failure mode that silently corrupts a sync.

Honest trade-offs

ShopScript is not Node. No npm, no async/await concurrency model, no long-lived process you can keep state in between requests. If your integration genuinely needs a specific npm package or a persistent connection pool of your own, you'll still want an external service — and fetch plus sw.secrets is a perfectly good way to talk to it.

The other sharp edge worth knowing up front: an sw.task.bg closure's source is captured as text when you enqueue it, so a long continuation chain keeps running the body it started with even after you redeploy. Modules pulled in with require() are re-resolved on every run, so the standard fix is to keep the closure a thin shell and put the real logic in a required file.

For genuinely heavy compute — OCR, image and video processing, PDF generation, ML inference — the answer isn't ShopScript at all. Those run as isolated container jobs, billed by the second on any paid plan, with a merchant-set monthly spend cap.

Where to start

Enable developer mode, authorize the shopswired CLI, and push a plugin directory; hooks are picked up from your exports automatically. The developer docs carry the full reference — every hook, every bridge signature, the field-level entity shapes, and worked recipes for order attribution, large CSV imports and unpaid-order recovery. When your plugin is ready, you can list it on the marketplace and keep 70% of each sale.

Plans start at Pro ($99/mo), and every plan carries 0% platform transaction fees — you pay only your payment processor's standard rate. See pricing for the compute rates and per-plan limits in one table. Pro is currently $1/month for the first 3 months.

← Back to Blog