← Back to Blog
Developers

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

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:

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 dev

The 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 token

Place 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

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-watch

push 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