Build Your First ShopsWired Plugin: A Step-by-Step Guide
A ShopsWired plugin is a directory with a manifest.json and some JavaScript files. You scaffold it with shopswired init, declare your scripts in the manifest, export functions named for the events you want to intercept, and run shopswired dev to have the store execute your local files on every save. No server, no webhook endpoint, no build step. This guide walks the whole loop with one small plugin that actually does something useful.
We're going to build Order Watch: it drops a notification in the merchant's admin tray when a large order gets paid, and runs a daily sweep for orders that are still awaiting payment. It touches the four things every plugin uses — the manifest, a ctx hook, a scheduled script, and the sw.* bridges — without being a toy.
Before you start
- A store on the Pro plan or higher. Building your own plugins is a Pro feature; running a store on ready-made themes and plugins is cheaper. See pricing.
- Developer Mode enabled on that store, under Settings → Developer. The CLI will refuse to link without it.
- The
shopswiredCLI on your PATH — a single prebuilt binary, no runtime to install. Download links are on the CLI page.
Use a throwaway store for this, not the one taking money. Your hooks will fire on real saves.
Step 1 — Authenticate and scaffold
shopswired login # device-code flow; opens the browser, no password in the terminal
shopswired link # pick the store to develop against
shopswired init --type plugin --id order-watch --name "Order Watch"init writes a plugins/order-watch/ directory containing a manifest.json and a main.js. The plugins/ and themes/ layout matters: shopswired dev scans for those subdirectories from your workspace root.
Step 2 — The manifest
The manifest is the plugin's identity plus a list of what runs. Replace the scaffolded one with this:
{
"id": "order-watch",
"name": "Order Watch",
"version": "1.0.0",
"scripts": [
{ "path": "hooks.js" },
{ "path": "sweep.js", "schedule": "0 9 * * *" }
],
"notify_categories": [
{ "key": "big_orders", "label": "Large orders" },
{ "key": "unpaid", "label": "Unpaid orders" }
],
"settings": [
{ "key": "alert_threshold", "type": "number", "label": "Alert above order total", "default": 500 },
{ "key": "unpaid_hours", "type": "number", "label": "Flag unpaid orders older than (hours)", "default": 24 }
]
}Three things worth noticing:
- You never list hook names. They're auto-detected from each script's
module.exportswhenever a.jsfile is saved. Add, rename or delete a hook and the next save picks it up. scheduleis a cron expression. A script with one becomes a scheduled job, and the merchant can also run it on demand from the ⚡ Actions menu on the plugin's settings page. Minimum interval is plan-dependent (15 minutes on Pro), so daily is comfortable.notify_categoriesis mandatory if you want to notify. A plugin can only post under a category it declares, and each one becomes its own opt-out row in every staff member's notification preferences. A plugin that declares none can't callsw.notify.createat all.
Settings values arrive in your script as ctx.settings.<key> (and on a global settings object). The default is applied when the field is left blank.
Step 3 — Your first ctx hook
Every handler's first argument is ctx. For a CRUD hook, ctx.data is the entity being written and ctx.old_data is its previous state — absent on a create. Mutating ctx.data in a before_save persists; throwing aborts the write. An after_save reacts to a write that already committed.
// hooks.js — alert the merchant the moment a large order is paid.
module.exports['order.after_save'] = (ctx) => {
const order = ctx.data;
const prev = ctx.old_data; // undefined on create
// Fire on the transition only. order.after_save runs again for every later
// edit — tracking numbers, notes, refunds — and must not re-alert.
if (order.payment?.status !== 'paid') return;
if (prev?.payment?.status === 'paid') return;
const threshold = (ctx.settings.alert_threshold || 500) * 100;
const total = order.totals?.total || 0; // order totals are integer cents
if (total < threshold) return;
sw.notify.create({
title: `Large order ${order.number}`,
category: 'big_orders', // must be a declared notify_categories key
body: `${(total / 100).toFixed(2)} ${order.currency || ''} — ${order.items.length} line(s)`,
severity: 'success', // info | success | warning | error
link: `/orders/${order.id}`, // admin deep link, opened on click
dedupeKey: `big-order:${order.id}` // no-op for ~10 min if the hook is retried
});
};The transition guard is the part people skip, and it's the part that generates angry merchant emails. order.after_save fires on every writer — an admin edit, an API call, another plugin's sw.orders.save — so "the order is paid" is true dozens of times over an order's life. Comparing against ctx.old_data is how you find the one moment it became true. dedupeKey is a short-window backstop, not a substitute.
Order totals are integer cents (order.totals.total), which is why the threshold is multiplied by 100. Money is cents everywhere in the API — get that wrong and your alert threshold is off by two orders of magnitude.
If you need a running count rather than an alert, don't do sw.storage.get → add 1 → sw.storage.set: two orders paid in the same moment both read the same number and one sale vanishes. Use the ledger, which serializes concurrent writers and takes an idempotency key:
sw.ledger.credit('sales', 'paid_orders', 1, { idemKey: `paid:${ctx.data.id}` });Step 4 — The live-preview loop
From your workspace root:
shopswired devThe store now runs your local files directly. Files are served from your machine on demand — nothing is uploaded, nothing is installed, and ending the session puts the store straight back to its installed copy. You preview at your normal shop URL, and plugin output streams into your terminal:
✓ Dev session active. Watching for changes...
12:15:03 ⟳ plugin/order-watch/hooks.js
12:15:04 LOG [order-watch/hooks.js] Order processed: #1042
12:15:10 ERR [order-watch/hooks.js] Payment failed: invalid tokenPlace a test order, mark it paid in the admin, and the notification should appear in the bell menu. When it doesn't, the logs tell you why rather than failing silently — a syntax error reports script failed to compile, a throw at the top level of a file reports script failed to load and names the hook that didn't run, and a hook registered but not exported as a function says exactly that. Raise the store's Log Level (Settings → Developer) to trace and you also get automatic timing for every hook and every sw.* bridge call.
Step 5 — A scheduled script
A scheduled script exports run. Same ctx, much bigger budget — 10 minutes instead of the 5 seconds a data hook gets.
// sweep.js — daily: flag orders still awaiting payment.
module.exports.run = (ctx) => {
const cutoff = Date.now() - (ctx.settings.unpaid_hours || 24) * 3600 * 1000;
const res = sw.orders.list({ filters: { status: 'created' }, limit: 100 });
let stale = 0;
for (const o of res.items) {
if (o.payment?.status === 'paid') continue; // already settled
if (new Date(o.created).getTime() > cutoff) continue; // still fresh
stale++;
}
console.log(`unpaid sweep: ${stale} stale order(s)`);
if (!stale) return;
sw.notify.create({
title: `${stale} order(s) still unpaid`,
category: 'unpaid',
severity: 'warning',
link: '/orders',
dedupeKey: `unpaid:${new Date().toISOString().slice(0, 10)}`
});
};status is indexed, so filtering on it is cheap. Paginated calls cap at 500 rows and throw if you ask for more — they never quietly return a short page, so follow res.cursor when you need the rest.
One honest caveat about this particular sweep: on an offline or manual store, every order sits at created until the merchant marks it paid, so an unscoped version of this would flag legitimate cash-on-delivery and invoice orders. Real plugins gate that on a setting or on payment.provider being a live gateway.
Step 6 — What will bite you
- Budgets are per script type. 1 second for render hooks, 5 for data hooks, 10 for checkout, 20 for payment, 30 for routes and widgets, 10 minutes for background and scheduled work.
ctx.timeoutRemaining()tells you what's left. Anything slow in a hook belongs insw.task.bg. - Hooks fire one level deep. A save made by a CRUD hook is written normally but fires no further hooks — otherwise two plugins would bounce a record between each other forever. Writes from a route, widget, scheduled script or background task start a fresh level.
- Write everything to be re-runnable. Scheduled scripts can be triggered off-cadence from the Actions menu, and actions can be re-run on a whim. Idempotency isn't optional.
- There is no npm.
require()resolves only inside your own plugin directory. Capability comes from thesw.*bridges and a browser-shapedfetch. We covered the runtime itself in What Is ShopScript?. - Tests live in
test/. They run in the same runtime against the realsw.*surface, and are never included in a published package — so they cost nothing in your plugin's size budget.
Step 7 — Push it, then publish it
When you're done with the dev loop, install the directory for real:
shopswired push --type plugin --id order-watchpush replaces the installed package with your directory and clears any edits made on the store — that's what makes a push mean something, because a file edited in the admin would otherwise keep shadowing the one you just sent. It checks first and refuses if the store has changes you'd destroy, naming them and telling you to pull or re-run with --force. Treat it like pushing to a shared branch: pull, look, push.
To list it on the marketplace, submit the version you currently have installed for review from the admin. A version number is only used up once it's been approved — a withdrawn or rejected version can be resubmitted under the same number after you've made changes, and only after approval do you need to bump version in the manifest to ship an update. Selling paid plugins means connecting a Stripe account; a platform fee applies, and earnings land in your Stripe balance and follow your own payout schedule. There's more on that in our marketplace guide.
Where to go next
You've now used every mechanism a plugin has: declared scripts, a ctx hook that watches a transition, a scheduled sweep, settings, and two bridges. The surface from here is wide — storefront HTTP routes, admin dashboard widgets, custom record types with their own CRUD hooks, payment and shipping and tax providers, custom Liquid filters, and tools you can hand to a merchant's AI assistant. The developer docs carry the full reference, including field-level entity shapes and worked recipes for order attribution, bulk CSV imports, live pricing and unpaid-order recovery.
Building your own plugins starts on Pro at $99/mo — currently $1/month for the first 3 months. Every plan carries 0% platform transaction fees; you pay only your payment processor's standard rate.
← Back to Blog