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:
- Mutate
ctx.datain place. Changed fields are read back by the engine. On data hooks,ctx.datais the entity andctx.old_datais its previous state (absent on create). - Throw to abort. A
before_*hook that throws blocks the operation; anafter_*throw is logged but doesn't roll back the write.
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:
cart.calculate_prices— override the per-line unit price.coupon.validate— custom coupon validation logic.shipping.calculate— compute or filter available shipping rates.tax.calculate— provide external tax calculation.payment.calculate_adjustment— add +/- adjustment lines based on the chosen payment method.payment.before_intent— a last validation before any gateway call.checkout.before_create/checkout.after_payment— validate/react as an order is placed.
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.