Acme Store Docs
Commerce Backends

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.

Subscribe from anywhere
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.

EventFired afterPayload
product.createdcommerce.products.create{ product: Product }
product.updatedcommerce.products.update{ id: string; patch: Partial<Product> }
product.deletedcommerce.products.delete{ id: string }
quote.submittedcommerce.quotes.submit{ quoteNumber: string; mainQuoteId: string; customerId: string | null }
quote.status_changedcommerce.quotes.updateStatus{ quote: Quote; status: QuoteStatus }
order.placedcommerce.orders.createFromQuote{ orderNumber: string; quote: Quote }
order.status_changedcommerce.orders.updateStatus{ order: Order; status: OrderStatus }
discount.createdcommerce.discounts.save{ discount: Discount }
discount.deletedcommerce.discounts.remove{ code: string }

Payload notes

A few payloads carry deliberately-shaped fields worth calling out:

  • quote.submittedquoteNumber is the human-readable number (e.g. Q-20260701-1234), mainQuoteId is the backend record id, and customerId is the signed-in customer's uid or null for a guest submission.
  • quote.status_changed / order.status_changedstatus is the new status that was persisted. The quote/order object is the record as passed into updateStatus, so its own .status field may still hold the previous value. Read the top-level status for the new state. Quote statuses are Pending, Quoted, Accepted, Declined; order statuses are Processing, Shipped, Delivered, Cancelled.
  • order.placedorderNumber is the generated human-readable number (e.g. O-20260701-1234); quote is the source quote that was promoted.
  • product.updatedpatch is exactly the partial that was applied, not the full product.
  • discount.created — this event covers both create and update (discounts.save is an upsert keyed by the uppercase code). The payload's discount.code is already normalised to uppercase, matching what was written.
  • discount.deletedcode is 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.

src/integrations/analytics.ts
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. emit returns 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 queueMicrotask callback. 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.error with the event name. It can never break the commerce operation, and it can never starve the other handlers.
Console output when a handler throws
[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.

src/integrations/orderWebhook.ts
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.

Pattern from src/lib/commerce/firebase.ts
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 }.

On this page