← Back to Blog
Developers

Ecommerce Event Hooks Explained: Customizing Checkout, Cart, and More

An event hook is a function you export that the platform calls at a specific moment in the shopping flow — a product save, a shipping calculation, an order being placed. On ShopsWired you customize checkout, cart, and pricing by exporting hook functions that receive a single ctx argument, read or mutate its data, and optionally throw to abort. No forked storefront, no webhooks to host — the code runs server-side, inline, on the same request.

The mental model: one export, one ctx

Every hook follows the same shape. You write a JavaScript file, export a function keyed by the hook name, and that function receives ctx. The export key is the subscription — the platform auto-detects hook names from your module.exports, so there's no separate registration step.

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

Two rules cover almost everything you'll do:

This runtime is called ShopScript — ShopsWired's server-side JavaScript runtime that powers plugin hooks, routes, widgets, and scheduled jobs. It's modern JavaScript (ES2015+): const/let, arrow functions, destructuring, optional chaining, for…of. The rest of this post is a tour of the hook families and what each one is for.

Data hooks: intercept every save and delete

The built-in entities — product, order, customer, coupon, wired_fulfillment — each fire the same four CRUD events: before_save, after_save, before_delete, and after_delete. Custom record types fire the same four under record.<type>.* (e.g. record.review.before_save).

Use before_save to validate or normalize before anything is persisted, and after_save to react to a committed write — roll up an average, raise a notification, enqueue a background task:

module.exports = {
    "record.review.before_save": function (ctx) {
        const rec = ctx.data;
        if (!rec.status) rec.status = "Pending";          // default a field
        if (!rec.rating) throw { error: "Rating required" }; // abort with a 403
    },
    "record.review.after_save": function (ctx) {
        const now  = ctx.data;            // committed values
        const prev = ctx.old_data || null; // null on create
        // e.g. recompute the product's average rating here
    }
};

These run inline on the write path with a short (~5s) budget, so keep them light and push heavy work to a background task. The payoff is that they fire for every write — an admin edit, a bridge call, or another plugin's save all pass through your hook, so it's a genuine single choke point for validation.

Cart and checkout hooks: where the money is

This is the family most people come for. ShopsWired exposes the whole purchase sequence as hooks:

Dynamic pricing with cart.calculate_prices

cart.calculate_prices fires every time the storefront computes cart prices — cart render, checkout render, and at the top of checkout submission. It's built for prices the catalog can't precompute: a live spot quote, a time-of-day discount, anything computed at request time.

module.exports = {
    "cart.calculate_prices": function (ctx) {
        // ctx.data.items = [{product_id, shop_id, name, set, qty, price}, ...]
        const items = ctx.data.items;
        for (let i = 0; i < items.length; i++) {
            const p = sw.products.get(items[i].product_id);
            if (!p || !p.attrs || !p.attrs.some(a => a.name === "Metal")) continue;
            items[i].price = computeLivePrice(p, items[i].qty); // cents
        }
    }
};

Only price is read back — touching qty or name has no effect. The engine recomputes the subtotal after your hook returns. One honest trade-off: display pricing and the final order snapshot are different moments. Keep the display fresh here, then bake the authoritative price into the order in checkout.before_create, which also reads back ctx.data.order.items[i].price — the last point before the order is persisted.

Guarding checkout

checkout.before_create is your authoritative gate. It sees the full order and ctx.request, so it can reject on order contents or on visitor network data — an IP or country check, say — before payment. Throw to block; include a redirect_url to bounce the buyer.

module.exports = {
    "checkout.before_create": function (ctx) {
        const h = ctx.request.headers;
        if (isBlockedIp(h["X-Real-Ip"]) || h["X-Geo-Country"] === "XX") {
            throw { error: "Checkout unavailable", redirect_url: "/cart" };
        }
    }
};

A note on B2B pricing: if all you need is "logged-in wholesale customers see a different price," you don't need a hook at all. ShopsWired has a native price-level system that applies across every storefront price path — listings, PDP, cart, and checkout — including inline-filter paths no hook can reach. Reach for cart.calculate_prices only when the price genuinely can't be precomputed.

Payment hooks: surcharges and gateways

payment.calculate_adjustment fires during submission when the form posts a payment_method. Push labeled +/- lines onto ctx.data.adjustments — a card surcharge, a wire-transfer discount — and they fold into the order total. Multiple plugins compose: each pushes its own entries and the engine keeps them all. For gateway integrations, payment.before_intent is the seam to run a final validation before any charge is attempted.

Search hooks: replace the engine

A script with type: "search" in its manifest replaces the built-in search entirely. You export four hooks — search.query, search.index, search.remove, and search.drop — and all indexing and querying flows through your plugin. search.query receives the query, filters, facets, and paging cursor on ctx.data, and you set ctx.data.products to an array of {id, shop_id} references plus a next-page cursor. That's the seam for wiring in an external search service while keeping the native storefront UI.

Render hooks: inject into the storefront

Two mechanisms cover the front end. template.before_render injects variables into Liquid templates before they render. And themes declare named slots with {% hook 'name' %}; any plugin that exports hook.<name> has its return value rendered into that slot. The default theme ships slots like head_end, product_after_price, checkout_review, and checkout_payment — the last is how gateway plugins render their SDK widget inside the checkout form.

Because these slots sit inside the checkout form, the simplest way to persist a custom field is to emit an input named meta[<key>] — the server parses every meta[*] field onto the order before checkout.before_create fires:

module.exports = {
    'hook.checkout_review': function (ctx) {
        return '<textarea name="meta[gift_message]" placeholder="Gift message…"></textarea>';
    }
};

Read it back later with sw.orders.get(id).meta.gift_message. No client-side plumbing required for plain fields.

Why hooks instead of webhooks

Traditional platforms hand you webhooks: an event fires, they POST to a server you host, and you reconcile after the fact. That's fine for reacting, but you can't block a checkout or rewrite a price from a webhook — the transaction already happened. ShopsWired hooks run inline, server-side, on the same request, so a before_* hook can veto or transform the operation before it commits. No server to run, no signature verification, no round-trip latency. You get the full field-level shapes of ctx and ctx.data in the docs, and every hook family shares the same one-argument pattern, so once you've written one you can write all of them.

The whole system is 0% platform transaction fees on every plan — you pay only your payment processor's standard rate — and the Pro plan is currently $1/month for the first three months. Spin up a store, drop a hooks file into a plugin, and customize checkout without asking anyone's permission. See pricing for the plan tiers, or dive into the developer docs for the per-hook ctx reference.

← Back to Blog