Lifecycle Events & Plugins
Subscribe to typed commerce lifecycle events to extend the store without forking.
The commerce layer emits a typed event after every successful mutation. This is the boilerplate's zero-dependency plugin hook: analytics, webhooks, email/Slack notifications, search-index or ERP sync jobs subscribe to these events instead of forking core files or wrapping service methods.
The bus lives in src/lib/commerce/events.ts — a tiny, typed, dependency-free event emitter (no EventEmitter, no mitt, no third-party library). Adapters emit into it; your integrations on it.
import { commerceEvents } from "@/lib/commerce";
const unsubscribe = commerceEvents.on("order.placed", ({ orderNumber, quote }) => {
console.log(`Order ${orderNumber} placed from quote ${quote.quoteNumber}`);
});
// later, to stop listening:
unsubscribe();Events are emitted strictly after the underlying backend call succeeds. A failed mutation emits nothing, so a subscriber never sees a change that didn't persist.
Why events instead of forking
The commerce layer is a backend seam: everything hangs off a single commerce object (see Backends overview). The event bus is the extension seam alongside it. When you need something to happen as a side effect of a commerce mutation — track it, notify someone, sync it elsewhere — you subscribe to an event rather than editing src/lib/commerce/firebase.ts or the storefront code that called it.
That keeps your integration code:
- Decoupled — your webhook or analytics call lives in your own module; core files stay pristine and upgradeable.
- Typed — the payload for each event is inferred from its name, so you get autocomplete and compile-time checks on the fields you read.
- Adapter-agnostic — every adapter emits the same events, so a subscriber written against the Firebase adapter keeps working after you swap in a SQL backend.
The event catalog
Nine lifecycle events are defined in the CommerceEventMap interface. Each fires immediately after the named commerce.* operation succeeds.
| Event | Fired after | Payload |
|---|---|---|
product.created | commerce.products.create | { product: Product } |
product.updated | commerce.products.update | { id: string; patch: Partial<Product> } |
product.deleted | commerce.products.delete | { id: string } |
quote.submitted | commerce.quotes.submit | { quoteNumber: string; mainQuoteId: string; customerId: string | null } |
quote.status_changed | commerce.quotes.updateStatus | { quote: Quote; status: QuoteStatus } |
order.placed | commerce.orders.createFromQuote | { orderNumber: string; quote: Quote } |
order.status_changed | commerce.orders.updateStatus | { order: Order; status: OrderStatus } |
discount.created | commerce.discounts.save | { discount: Discount } |
discount.deleted | commerce.discounts.remove | { code: string } |
Payload notes
A few payloads carry deliberately-shaped fields worth calling out:
quote.submitted—quoteNumberis the human-readable number (e.g.Q-20260701-1234),mainQuoteIdis the backend record id, andcustomerIdis the signed-in customer's uid ornullfor a guest submission.quote.status_changed/order.status_changed—statusis the new status that was persisted. Thequote/orderobject is the record as passed intoupdateStatus, so its own.statusfield may still hold the previous value. Read the top-levelstatusfor the new state. Quote statuses arePending,Quoted,Accepted,Declined; order statuses areProcessing,Shipped,Delivered,Cancelled.order.placed—orderNumberis the generated human-readable number (e.g.O-20260701-1234);quoteis the source quote that was promoted.product.updated—patchis exactly the partial that was applied, not the full product.discount.created— this event covers both create and update (discounts.saveis an upsert keyed by the uppercase code). The payload'sdiscount.codeis already normalised to uppercase, matching what was written.discount.deleted—codeis the uppercase, trimmed code.
discount.created and discount.deleted are real events, but the discount engine only touches the native quote flow — it never runs on the Stripe checkout path.
The subscriber API
The bus is exported both as a namespace object (commerceEvents) and as tree-shakeable named functions (on, once, emit). Application code normally only ever calls on / once.
commerceEvents.on(event, handler)
Subscribe to an event. Returns an unsubscribe function — call it to remove the registration. The handler may be sync or async.
import { commerceEvents } from "@/lib/commerce";
const off = commerceEvents.on("quote.submitted", ({ quoteNumber, customerId }) => {
analytics.track("Quote Requested", {
quoteNumber,
guest: customerId === null,
});
});
// off() removes just this registration; calling it twice is a no-op.The same handler may be registered multiple times (it runs once per registration). When several handlers subscribe to one event, they run in registration order.
commerceEvents.once(event, handler)
Subscribe for a single occurrence. The handler is unsubscribed before it runs, so it fires at most once even if it throws. Returns an unsubscribe function you can call to cancel before the event ever fires.
commerceEvents.once("order.placed", ({ orderNumber }) => {
console.log(`First order of the session: ${orderNumber}`);
});commerceEvents.emit(event, payload)
Emit an event to all current subscribers. This is for adapters, not application code — adapters call it strictly after a backend mutation succeeds. You only need emit if you are writing a new commerce adapter (see below).
Execution & error semantics
The bus is intentionally forgiving so a buggy subscriber can never break a sale:
- Fire-and-forget.
emitreturns immediately and never throws. The commerce operation that triggered the event has already resolved by the time handlers run. - Microtask-scheduled. Each handler runs in its own
queueMicrotaskcallback. Handlers are dispatched from a snapshot of the subscriber list taken at emit time, so subscribing or unsubscribing during dispatch does not affect the in-flight emission. - Isolated failures. A handler that throws — or returns a rejecting promise — is caught and reported via
console.errorwith the event name. It can never break the commerce operation, and it can never starve the other handlers.
[commerce.events] handler for "order.placed" threw: Error: ...
[commerce.events] async handler for "order.placed" rejected: Error: ...Because handlers are fire-and-forget, do not rely on an event handler to complete before a response is sent, or to retry on failure. For guaranteed delivery (e.g. a webhook that must not drop), have your handler enqueue durable work (a queue row, a job) rather than doing the delivery inline.
Where handlers run
Events fire in whatever runtime executes the mutation.
The default Firebase adapter performs its writes client-side, so its events fire in the browser. Subscribe from a module that is loaded on the client — a top-level provider, or a module imported by your app entry point — so the registration exists before the mutation happens.
With the client-side Firebase adapter, subscribers run in the browser. Never put third-party API secrets in a client-side handler. For work that needs secrets, either call your own /api/* route from the handler, or run a server-side adapter (see below).
A server-side adapter — the SQL backends behind the /api/commerce RPC route (supabase / postgres), an /api/admin/* route layer, or a hosted Medusa integration — emits server-side, where subscribers can safely call third-party APIs with secrets. See the Commerce RPC route for how SQL backends run server-side, and Backends overview for how the active adapter is selected.
Worked example: a webhook subscriber
A typical integration: forward every placed order to an external system. Register once, on the correct runtime for your adapter.
import { commerceEvents } from "@/lib/commerce";
/**
* Forward every placed order to an external endpoint.
* Register this once, where the mutation runs (server-side for a SQL
* backend; a client provider for the default Firebase adapter).
*/
export function registerOrderWebhook() {
return commerceEvents.on("order.placed", async ({ orderNumber, quote }) => {
await fetch(process.env.ORDER_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orderNumber,
quoteNumber: quote.quoteNumber,
customerId: quote.customerId,
lineCount: quote.items.length,
}),
});
});
}Because the handler is async, a network failure rejects the returned promise — which the bus catches and logs, leaving the order itself untouched.
Writing an adapter that emits events
If you build a new backend adapter, emit the same events after each successful mutation so existing subscribers keep working. Import emit from the events module and call it after the underlying call resolves, passing the payload through unchanged.
import { emit } from "./events";
// products.create
create: async (product) => {
await FirebaseDBService.addProduct(product);
emit("product.created", { product });
},
// quotes.submit
submit: async (userId, quoteData, customerInfo, message) => {
const result = await FirebaseDBService.saveQuote(
userId, quoteData, customerInfo, message,
);
emit("quote.submitted", {
quoteNumber: result.userQuoteId,
mainQuoteId: result.mainQuoteId,
customerId: userId,
});
return result;
},The rule is: emit post-success only, return results unchanged, and match the payload shapes in CommerceEventMap exactly. TypeScript enforces the shape — emit("order.placed", …) won't compile unless the payload matches { orderNumber: string; quote: Quote }.