Subscribers are how you attach behaviour to things that happen without wiring it into the thing that happened. An order is placed; four unrelated systems want to know. None of them belong in the order workflow.
The judgement call — and it is the only hard part — is knowing when something is a subscriber and when it is a workflow step.
Writing one
A file under src/subscribers/, exporting a handler and a config:
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { Modules } from "@medusajs/framework/utils"
export default async function orderPlacedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
const notification = container.resolve(Modules.NOTIFICATION)
const query = container.resolve("query")
const { data: [order] } = await query.graph({
entity: "order",
fields: ["id", "display_id", "email", "total", "items.*"],
filters: { id: data.id },
})
if (!order) {
logger.warn(`[order-placed] order ${data.id} not found`)
return
}
await notification.createNotifications({
to: order.email,
channel: "email",
template: "order-confirmation",
data: { order },
})
}
export const config: SubscriberConfig = {
event: "order.placed",
}Note the event payload carries an id, not the full record. Fetch what you need. This is deliberate — payloads stay small and you always read current state rather than a snapshot from whenever the event was queued.
Subscriber or workflow step?
The question to ask: if this fails, should the original operation be undone?
| Requirement | Use |
|---|---|
| Order must not complete without reserving stock | Workflow step |
| Order must not complete without charging the card | Workflow step |
| Send a confirmation email | Subscriber |
| Sync the order to the ERP | Subscriber (with retries) |
| Update a search index | Subscriber |
| Notify a Slack channel | Subscriber |
| Enforce a fraud rule before capture | Workflow step |
The distinction is transactional necessity, not importance. The ERP sync matters enormously; it still should not prevent a customer from checking out.
Emitting custom events
Your own modules should emit events for the same reason core does — so that other people's code can react without you knowing about it.
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { Modules } from "@medusajs/framework/utils"
export const emitWishlistSharedStep = createStep(
"emit-wishlist-shared",
async (input: { wishlist_id: string }, { container }) => {
const eventBus = container.resolve(Modules.EVENT_BUS)
await eventBus.emit({
name: "wishlist.shared",
data: { id: input.wishlist_id },
})
return new StepResponse({ emitted: true })
}
)Name events noun.verb-past-tense: wishlist.shared, subscription.renewed, quote.approved. Consistency here pays off the first time someone greps for what a system can tell them.
Configure Redis in production
The default event bus is in-memory: events are lost on restart, and they do not cross process boundaries. Neither is acceptable once you run more than one instance.
module.exports = defineConfig({
modules: [
{
resolve: "@medusajs/medusa/event-bus-redis",
options: {
redisUrl: process.env.EVENTS_REDIS_URL,
},
},
],
})With Redis you get durability across restarts, delivery across instances, and retries with backoff. If you take one thing from this post, take this one — it is a two-line change that prevents a category of silent data loss.
Idempotency is not optional
Subscribers retry on failure. A handler that sends an email and then throws while writing a log line will send that email again.
Make the effect safe to repeat:
const alreadySent = await notificationService.listNotifications({
resource_id: order.id,
template: "order-confirmation",
})
if (alreadySent.length) {
return
}Or push idempotency to the provider — most email and webhook APIs accept an idempotency key. Either approach is fine; having neither is how customers get four confirmations.
Practical guidance
One subscriber, one concern. Separate files for the confirmation email, the ERP sync and the search index update. When the ERP is down, only the ERP subscriber retries.
Log the event id and outcome. Subscribers fail invisibly by nature. Structured logs are the only way you will know.
Never let a subscriber block a response. They are asynchronous by design; do not await them from a request handler.
Treat handler errors as expected. External systems fail. Catch, log with context, and let the retry happen.
A catalogue of the events you will actually use
Core modules emit a lot. These are the ones that carry most integrations:
| Event | Typical subscriber |
|---|---|
| `order.placed` | Confirmation email, ERP sync, analytics |
| `order.canceled` | Restock, refund reconciliation |
| `order.fulfillment_created` | Shipment notification, tracking upload |
| `product.created` / `product.updated` | Search index, storefront revalidation |
| `product.deleted` | Index removal, redirect creation |
| `customer.created` | Marketing list, welcome sequence |
| `payment.captured` | Accounting, revenue recognition |
| `invite.created` | Staff onboarding email |
When you need to discover what a flow emits, add a temporary catch-all subscriber that logs every event name, run the flow once, then delete it. Faster than reading source.
Ordering and independence
Subscribers do not run in a guaranteed order, and they must not depend on each other. If your search-index subscriber assumes the ERP subscriber has already run, you have built a distributed race condition.
When work genuinely must be sequenced, it belongs in one workflow with ordered steps, triggered by a single subscriber:
export default async function orderPlacedHandler({ event, container }: SubscriberArgs<{ id: string }>) {
// One subscriber, one workflow, explicit ordering inside it.
await postOrderPipelineWorkflow(container).run({ input: { order_id: event.data.id } })
}That also gives the sequence compensation, which a set of independent subscribers can never have.
Testing subscribers
Subscribers are easy to test badly — asserting that a handler ran tells you nothing. Assert the effect:
medusaIntegrationTestRunner({
testSuite: ({ getContainer }) => {
it("does not send a second confirmation on redelivery", async () => {
const container = getContainer()
const order = await seedOrder(container)
await orderPlacedHandler({ event: { data: { id: order.id } }, container } as never)
await orderPlacedHandler({ event: { data: { id: order.id } }, container } as never)
const sent = await container.resolve(Modules.NOTIFICATION).listNotifications({
resource_id: order.id,
})
expect(sent).toHaveLength(1)
})
},
})Calling the handler twice is the test that matters, because redelivery is normal and duplicate customer emails are the visible failure. Testing Medusa.
Events are the cheapest way to keep a commerce codebase from turning into one enormous order function. We are happy to review yours.
Frequently asked questions
What is the difference between a subscriber and a workflow step in Medusa?
A workflow step is part of the operation: if it fails, earlier steps compensate and the operation does not complete. A subscriber runs after the fact, asynchronously, and cannot undo anything. Use a step when the operation must not succeed without your logic, and a subscriber for everything downstream.
How do I see the events Medusa emits?
Core modules emit documented events such as `order.placed`, `product.created` and `customer.created`. The reliable way to see them in your own application is a temporary catch-all subscriber that logs the event name, then remove it once you know what you need.
Do Medusa subscribers retry on failure?
With the Redis event bus, yes — failed handlers are retried with backoff. With the default in-memory bus there is no durability at all. This is the main reason to configure Redis before going to production.
How do I emit a custom event?
Resolve the event bus from the container and call `emit` with a name and a small data payload — usually just an id. Do it inside a workflow step so the emission participates in the workflow rather than firing on an operation that later rolls back.
Why does my subscriber run twice?
Retries after a partial failure, or multiple application instances both subscribing without a shared bus. Configure the Redis event bus and make handlers idempotent by checking whether the effect already happened before repeating it.
Can a subscriber run a workflow?
Yes, and it is the recommended pattern for anything non-trivial. The subscriber receives the container, so `myWorkflow(container).run({ input })` works, and you get compensation for the work the subscriber triggers.
Medusa Project Structure: Where Every Kind of Code Belongs
Medusa loads code by convention. A directory-by-directory guide to what goes where, and the decision tree for placing any new piece of logic.
The Medusa Container: Dependency Injection Without the Ceremony
Everything in Medusa is resolved from a container. What is registered, how scoping works per request, and how to avoid the two mistakes that cause leaks.
Medusa Module Links: Relating Data Across Isolated Modules
Modules cannot reference each other's tables, so how do you attach a brand to a product? Links — and the query layer that reads across them.



